Basic functions, Home and Community Tab

This commit is contained in:
Malte Schröder
2025-12-17 20:41:51 +01:00
commit 506f12adc2
28 changed files with 1086 additions and 0 deletions

62
src/app.rs Normal file
View File

@@ -0,0 +1,62 @@
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Title};
use leptos_router::components::{Route, Router, Routes, A};
use leptos_router::path;
use crate::pages::{home::HomePage, community::CommunityPage};
use crate::backend::logic::get_server_time;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
// WICHTIG: Der Schrägstrich / vor pkg sorgt dafür, dass das CSS überall geladen wird
<link rel="stylesheet" id="leptos" href="/pkg/bytemalte_de.css"/>
<AutoReload options=options.clone() />
<HydrationScripts options=options/>
<MetaTags/>
</head>
<body><App/></body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
let time = Resource::new(|| (), |_| async move { get_server_time().await });
view! {
<Title text="Malxtes Community"/>
<Router>
<header class="navbar">
<nav class="nav-content">
<div class="nav-logo">"Malxte."</div>
<div class="nav-links">
<A href="/" exact=true>"Home"</A>
<A href="/community">"Community"</A>
</div>
</nav>
</header>
<main class="page-content">
<Routes fallback=|| "404">
<Route path=path!("") view=HomePage/>
<Route path=path!("/community") view=CommunityPage/>
</Routes>
</main>
<footer class="app-footer">
<Suspense fallback=|| view! { <span class="server-time">"Verbinde..."</span> }>
{move || time.get().map(|t| view! {
<span class="server-time">
"Server Status: Online • " {t.unwrap_or_default()}
</span>
})}
</Suspense>
</footer>
</Router>
}
}

10
src/backend/db.rs Normal file
View File

@@ -0,0 +1,10 @@
#[cfg(feature = "ssr")]
use sqlx::SqlitePool;
#[cfg(feature = "ssr")]
pub async fn build_db_pool() -> SqlitePool {
let database_url = "sqlite:community.db";
let pool = SqlitePool::connect(database_url).await.expect("DB Fehler");
sqlx::migrate!("./migrations").run(&pool).await.expect("Migration Fehler");
pool
}

45
src/backend/logic.rs Normal file
View File

@@ -0,0 +1,45 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChatMessage {
pub username: String,
pub content: String,
}
#[server]
pub async fn login_or_register(user: String, pass: String) -> Result<i64, ServerFnError> {
use sqlx::SqlitePool;
use bcrypt::{hash, verify, DEFAULT_COST};
let pool = use_context::<SqlitePool>().unwrap();
let existing = sqlx::query!("SELECT id, password_hash FROM users WHERE username = ?", user).fetch_optional(&pool).await?;
if let Some(u) = existing {
if verify(&pass, &u.password_hash).unwrap_or(false) { Ok(u.id.expect("ID fehlt")) }
else { Err(ServerFnError::new("Falsches Passwort")) }
} else {
let hashed = hash(&pass, DEFAULT_COST).unwrap();
let id = sqlx::query!("INSERT INTO users (username, password_hash) VALUES (?, ?)", user, hashed)
.execute(&pool).await?.last_insert_rowid();
Ok(id)
}
}
#[server]
pub async fn send_message(user_id: i64, content: String) -> Result<(), ServerFnError> {
let pool = use_context::<sqlx::SqlitePool>().unwrap();
sqlx::query!("INSERT INTO messages (user_id, content) VALUES (?, ?)", user_id, content).execute(&pool).await?;
Ok(())
}
#[server]
pub async fn get_all_messages() -> Result<Vec<ChatMessage>, ServerFnError> {
let pool = use_context::<sqlx::SqlitePool>().unwrap();
let rows = sqlx::query!("SELECT u.username, m.content FROM messages m JOIN users u ON m.user_id = u.id ORDER BY m.created_at ASC").fetch_all(&pool).await?;
Ok(rows.into_iter().map(|r| ChatMessage { username: r.username, content: r.content }).collect())
}
#[server]
pub async fn get_server_time() -> Result<String, ServerFnError> {
Ok(chrono::Local::now().to_rfc3339())
}

2
src/backend/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod db; // Entferne das #[cfg(feature = "ssr")]
pub mod logic; // Entferne das #[cfg(feature = "ssr")]

12
src/lib.rs Normal file
View File

@@ -0,0 +1,12 @@
pub mod app;
pub mod pages;
pub mod backend;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::*;
use leptos::prelude::*;
console_error_panic_hook::set_once();
hydrate_body(App);
}

42
src/main.rs Normal file
View File

@@ -0,0 +1,42 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use bytemalte_de::app::*;
use bytemalte_de::backend::db::build_db_pool;
// Konfiguration laden
let conf = get_configuration(None).unwrap();
let leptos_options = conf.leptos_options.clone(); // Klonen für den Router
let addr = leptos_options.site_addr; // Adresse kopieren, bevor wir verschieben
// Datenbank vorbereiten
let db_pool = build_db_pool().await;
let routes = generate_route_list(App);
let app = Router::new()
.leptos_routes_with_context(
&leptos_options,
routes,
{
let pool = db_pool.clone();
move || { provide_context(pool.clone()); }
},
{
let opts = leptos_options.clone();
move || shell(opts.clone())
}
)
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
// Server starten mit der vorher gespeicherten Adresse
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
leptos::logging::log!("Server läuft auf http://{}", &addr);
axum::serve(listener, app.into_make_service()).await.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {}

144
src/pages/community.rs Normal file
View File

@@ -0,0 +1,144 @@
use leptos::prelude::*;
use leptos::html::Div;
use crate::backend::logic::*;
#[component]
pub fn CommunityPage() -> impl IntoView {
// --- STATE & SIGNALS ---
// Wer ist eingeloggt? (ID und Benutzername)
let (user_id, set_user_id) = signal(None::<i64>);
let (username, set_username) = signal(String::new());
// Referenz auf das Chat-Fenster für automatisches Scrollen
let scroll_ref = NodeRef::<Div>::new();
// --- DATEN-ABFRAGE (RESOURCE) ---
// Holt die Nachrichten vom Server
let messages = Resource::new(|| (), |_| async move {
get_all_messages().await.unwrap_or_default()
});
// --- ECHTZEIT-POLLING ---
// Fragt alle 2 Sekunden den Server nach neuen Nachrichten (nur im Browser)
#[cfg(not(feature = "ssr"))]
{
use std::time::Duration;
Effect::new(move |_| {
let _ = set_interval_with_handle(move || {
messages.refetch();
}, Duration::from_secs(2));
});
}
// --- AUTO-SCROLL EFFEKT ---
// Immer wenn neue Nachrichten geladen werden, scrollen wir nach ganz unten
Effect::new(move |_| {
messages.get(); // Reagiere auf Änderungen der Nachrichten
if let Some(div) = scroll_ref.get() {
request_animation_frame(move || {
div.set_scroll_top(div.scroll_height());
});
}
});
// --- SERVER ACTIONS ---
let login_action = ServerAction::<LoginOrRegister>::new();
let send_action = ServerAction::<SendMessage>::new();
// --- LOGIK-EFFEKTE ---
// 1. Login-Erfolg verarbeiten
Effect::new(move |_| {
if let Some(Ok(id)) = login_action.value().get() {
set_user_id.set(Some(id));
}
});
// 2. Nach dem Senden sofort Nachrichten aktualisieren
Effect::new(move |_| {
if send_action.value().get().is_some() {
messages.refetch();
}
});
// --- VIEW / UI ---
view! {
<div class="community-container">
{move || match user_id.get() {
// FALL A: Nutzer ist nicht eingeloggt (Anmelde-Karte)
None => view! {
<div class="auth-card">
<h2>"Community Login"</h2>
<p>"Tritt der Diskussion bei und schreibe mit Malxte."</p>
<ActionForm action=login_action>
<input
type="text"
name="user"
placeholder="Dein Name"
on:input:target=move |ev| set_username.set(ev.target().value())
required
/>
<input
type="password"
name="pass"
placeholder="Passwort"
required
/>
<button type="submit">"Beitreten"</button>
</ActionForm>
// Fehlermeldung anzeigen, falls Login fehlschlägt
{move || login_action.value().get().map(|v| match v {
Err(e) => view! { <p style="color: red; margin-top: 10px;">{e.to_string()}</p> }.into_any(),
_ => view! {}.into_any()
})}
</div>
}.into_any(),
// FALL B: Nutzer ist eingeloggt (Chat-Fenster)
Some(id) => view! {
<div class="chat-main">
<header class="chat-top-bar">
<h3>"Globaler Chat"</h3>
<span class="badge">{move || username.get()}</span>
</header>
<div class="message-area" node_ref=scroll_ref>
<Suspense fallback=|| view! { <div class="loading">"Lade Nachrichten..."</div> }>
{move || messages.get().map(|list| {
list.into_iter().map(|msg| {
let is_me = msg.username == username.get();
view! {
<div class=if is_me { "msg-row me" } else { "msg-row other" }>
<div class="msg-bubble">
<strong>{msg.username}</strong>
<p>{msg.content}</p>
</div>
</div>
}
}).collect_view()
})}
</Suspense>
</div>
<div class="chat-input-wrapper">
<ActionForm action=send_action>
// Die ID wird versteckt mitgesendet
<input type="hidden" name="user_id" value=id.to_string() />
<div class="input-group">
<input
type="text"
name="content"
placeholder="Deine Nachricht..."
required
autocomplete="off"
/>
<button type="submit">"Senden"</button>
</div>
</ActionForm>
</div>
</div>
}.into_any()
}}
</div>
}
}

9
src/pages/dashboard.rs Normal file
View File

@@ -0,0 +1,9 @@
use leptos::prelude::*;
#[component]
pub fn DashboardPage() -> impl IntoView {
view! {
<h1>"Dashboard"</h1>
<p>"Hier werden später Community-Statistiken stehen."</p>
}
}

11
src/pages/home.rs Normal file
View File

@@ -0,0 +1,11 @@
use leptos::prelude::*;
#[component]
pub fn HomePage() -> impl IntoView {
view! {
<div class="home-hero">
<h1>"Malxte."</h1>
<p>"Willkommen in der Rust-Community."</p>
</div>
}
}

2
src/pages/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod home;
pub mod community; // Das hat gefehlt!