Datei-basiertes CMS für Projekte implementiert und Scroll-Animationen hinzugefügt
This commit is contained in:
@@ -20,8 +20,10 @@ leptos_axum = { version = "0.8.0", optional = true }
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||||
sqlx = { version = "0.8.2", features = ["runtime-tokio-rustls", "sqlite", "chrono"], optional = true }
|
sqlx = { version = "0.8.2", features = ["runtime-tokio-rustls", "sqlite", "chrono"], optional = true }
|
||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
web-sys = { version = "0.3.83", features = ["Window", "Document", "Element", "HtmlElement"] }
|
web-sys = { version = "0.3.83", features = ["Window", "Document", "Element", "HtmlElement", "IntersectionObserver", "IntersectionObserverEntry", "IntersectionObserverInit", "DomRectReadOnly"] }
|
||||||
gloo-events = "0.1"
|
gloo-events = "0.1"
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
js-sys = "0.3.85"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
hydrate = ["leptos/hydrate", "dep:console_error_panic_hook", "dep:wasm-bindgen"]
|
hydrate = ["leptos/hydrate", "dep:console_error_panic_hook", "dep:wasm-bindgen"]
|
||||||
|
|||||||
114
README.md
114
README.md
@@ -1,99 +1,87 @@
|
|||||||
bytemalte_de – Community Chat
|
bytemalte_de – Community Chat & Projekt-Portfolio
|
||||||
|
|
||||||
Dies ist ein Fullstack-Projekt für einen modernen Gruppenchat, entwickelt mit:
|
Dies ist ein Fullstack-Projekt für eine moderne Community-Plattform, entwickelt mit:
|
||||||
- Leptos – Web-Framework aus Rust
|
|
||||||
- cargo-leptos – Fullstack-Build-Tool
|
|
||||||
- Axum – Webserver-Framework
|
|
||||||
- SQLite – Leichte Embedded-Datenbank
|
|
||||||
|
|
||||||
|
Leptos (0.8) – Web-Framework aus Rust (Frontend & SSR)
|
||||||
|
|
||||||
|
cargo-leptos – Fullstack-Build-Tool
|
||||||
|
|
||||||
|
Axum – Webserver-Framework
|
||||||
|
|
||||||
|
SQLite & SQLx – Datenbank für Chat & Benutzer
|
||||||
|
|
||||||
|
File-based CMS – JSON-basiertes System für Projekt-Verwaltung
|
||||||
|
|
||||||
|
Projekt-Features
|
||||||
|
|
||||||
|
Echtzeit-Chat: Benutzerregistrierung und Gruppenchat mit SQLite-Backend.
|
||||||
|
|
||||||
|
Projekt-Portfolio: Dynamische Anzeige von Projekten direkt aus einer projects.json.
|
||||||
|
|
||||||
|
Infinite Scroll: Performantes Nachladen von Inhalten auf der Startseite.
|
||||||
|
|
||||||
|
Server Side Rendering (SSR): Schnelle Ladezeiten und SEO-Optimierung.
|
||||||
|
|
||||||
Projekt-Setup
|
Projekt-Setup
|
||||||
|
|
||||||
1. Datenbank konfigurieren
|
Voraussetzungen & Tools Stelle sicher, dass Rust installiert ist. Installiere zusätzlich die benötigten Komponenten:
|
||||||
|
|
||||||
Erstelle im Hauptverzeichnis eine .env Datei mit folgendem Inhalt:
|
rustup target add wasm32-unknown-unknown cargo install cargo-leptos cargo install sqlx-cli --no-default-features --features sqlite
|
||||||
DATABASE_URL="sqlite:community.db"
|
|
||||||
|
|
||||||
Initialisiere anschließend die Datenbank und führe Migrationen aus:
|
SASS Compiler: Fedora: sudo dnf install dart-sass Oder via npm: npm install -g sass
|
||||||
sqlx db create
|
|
||||||
sqlx migrate run
|
|
||||||
|
|
||||||
Damit werden die Tabellen für Benutzer und Nachrichten in der Datei community.db angelegt.
|
Datenbank & Umgebung Erstelle eine .env Datei im Hauptverzeichnis: DATABASE_URL="sqlite:community.db"
|
||||||
|
|
||||||
|
Initialisiere die Datenbank und führe Migrationen aus: sqlx db create sqlx migrate run
|
||||||
|
|
||||||
2. Entwicklungsserver starten
|
CMS Konfiguration (Projekte) Die Projekte auf der Startseite werden über die Datei projects.json gesteuert. Du kannst Projekte hinzufügen oder ändern, ohne den Code neu zu kompilieren. Die Datei muss im Hauptverzeichnis liegen.
|
||||||
|
|
||||||
Starte das Projekt mit Hot-Reload:
|
Struktur der projects.json: [ { "id": 1, "title": "Projekt Name", "description": "Beschreibung...", "image_url": "/assets/bild.png", "download_url": "#", "code_url": "https://..." } ]
|
||||||
cargo leptos watch
|
|
||||||
|
|
||||||
Nach erfolgreicher Kompilierung (WASM für das Frontend und Binärdatei für das Backend) ist die Anwendung unter
|
Entwicklungsserver starten
|
||||||
http://127.0.0.1:3000 erreichbar.
|
|
||||||
|
|
||||||
|
Starte das Projekt mit Hot-Reload: cargo leptos watch
|
||||||
|
|
||||||
Zusätzliche Tools und Konfiguration
|
Nach erfolgreicher Kompilierung ist die Anwendung unter http://127.0.0.1:3000 erreichbar.
|
||||||
|
|
||||||
Dieses Projekt nutzt kryptografische Funktionen (bcrypt) und Datenbankzugriffe. Stelle sicher, dass folgende Tools installiert sind:
|
Zusätzliche Konfiguration
|
||||||
|
|
||||||
rustup target add wasm32-unknown-unknown
|
Füge folgende Konfiguration in .cargo/config.toml hinzu, um das WASM-Krypto-Backend (bcrypt) zu aktivieren:
|
||||||
cargo install sqlx-cli --no-default-features --features sqlite
|
|
||||||
|
|
||||||
SASS Compiler:
|
|
||||||
Fedora:
|
|
||||||
sudo dnf install dart-sass
|
|
||||||
oder mit npm:
|
|
||||||
npm install -g sass
|
|
||||||
|
|
||||||
Füge folgende Konfiguration in .cargo/config.toml hinzu, um das WASM-Krypto-Backend zu aktivieren:
|
|
||||||
[target.wasm32-unknown-unknown]
|
|
||||||
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]
|
|
||||||
|
|
||||||
|
[target.wasm32-unknown-unknown] rustflags = ["--cfg", "getrandom_backend="wasm_js""]
|
||||||
|
|
||||||
Produktion / Release Build
|
Produktion / Release Build
|
||||||
|
|
||||||
Erzeuge eine optimierte Release-Version:
|
Erzeuge eine optimierte Release-Version: cargo leptos build --release
|
||||||
cargo leptos build --release
|
|
||||||
|
|
||||||
Dieser Befehl erstellt:
|
Dieser Befehl erstellt:
|
||||||
- Die Server-Binärdatei unter target/release/bytemalte_de
|
|
||||||
- Das Frontend-Paket (WASM/JS/CSS) unter target/site
|
|
||||||
|
|
||||||
|
Die Server-Binärdatei unter target/release/bytemalte_de
|
||||||
|
|
||||||
Tests ausführen
|
Das Frontend-Paket (WASM/JS/CSS) unter target/site
|
||||||
|
|
||||||
Führe End-to-End-Tests mit Playwright aus:
|
|
||||||
cargo leptos end-to-end
|
|
||||||
|
|
||||||
Die Tests befinden sich im Verzeichnis end2end/tests.
|
|
||||||
|
|
||||||
|
|
||||||
Deployment auf einem entfernten Server (Ubuntu 24.04 VPS)
|
Deployment auf einem entfernten Server (Ubuntu 24.04 VPS)
|
||||||
|
|
||||||
Nach cargo leptos build --release benötigst du auf dem Zielserver folgende Dateien:
|
Nach dem Build benötigst du auf dem Zielserver folgende Dateien:
|
||||||
- target/release/bytemalte_de
|
|
||||||
- target/site/
|
|
||||||
- community.db
|
|
||||||
- .env
|
|
||||||
|
|
||||||
Beispielhafte Verzeichnisstruktur:
|
die Binärdatei (bytemalte_de)
|
||||||
bytemalte_de/
|
|
||||||
├── site/
|
|
||||||
├── community.db
|
|
||||||
└── .env
|
|
||||||
|
|
||||||
Exportiere die Umgebungsvariablen (z. B. in einem Systemd-Service):
|
den Ordner site/
|
||||||
export LEPTOS_OUTPUT_NAME="bytemalte_de"
|
|
||||||
export LEPTOS_SITE_ROOT="site"
|
|
||||||
export LEPTOS_SITE_PKG_DIR="pkg"
|
|
||||||
export LEPTOS_SITE_ADDR="0.0.0.0:3000"
|
|
||||||
export DATABASE_URL="sqlite:community.db"
|
|
||||||
|
|
||||||
Starte die Anwendung:
|
projects.json
|
||||||
./bytemalte_de
|
|
||||||
|
|
||||||
|
community.db
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
Beispielhafte Verzeichnisstruktur: bytemalte_de/ ├── site/ ├── projects.json ├── community.db └── .env
|
||||||
|
|
||||||
|
Exportiere die Umgebungsvariablen (z. B. in einem Systemd-Service): export LEPTOS_OUTPUT_NAME="bytemalte_de" export LEPTOS_SITE_ROOT="site" export LEPTOS_SITE_PKG_DIR="pkg" export LEPTOS_SITE_ADDR="0.0.0.0:3000" export DATABASE_URL="sqlite:community.db"
|
||||||
|
|
||||||
|
Anwendung starten: ./bytemalte_de
|
||||||
|
|
||||||
Lizenz & Urheberrecht
|
Lizenz & Urheberrecht
|
||||||
|
|
||||||
Dieses Projekt basiert auf dem Leptos Axum Starter Template.
|
Dieses Projekt basiert auf dem Leptos Axum Starter Template. Die spezifische Chat-Logik, das JSON-CMS, das Styling und UI-Design wurden individuell für bytemalte_de entwickelt.
|
||||||
Die spezifische Chat-Logik, das Styling und UI-Design wurden individuell für bytemalte_de entwickelt.
|
|
||||||
|
|
||||||
Entwickelt mit ❤️ von bytemalte
|
Entwickelt mit ❤️ von bytemalte
|
||||||
|
|||||||
178
projects.json
Normal file
178
projects.json
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Malxte.de Persönliche Rust Website",
|
||||||
|
"description": "Meine persönliche Website komplett mit Rust und Yew programmiert.",
|
||||||
|
"image_url": "/assets/malxte_de.png",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "https://gitea.malxte.de/Bytemalte/malxte_de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Bytemalte.de Rust Community Website",
|
||||||
|
"description": "Eine in Rust und mit Leptos programmierte Community Website.",
|
||||||
|
"image_url": "/assets/bytemalte_de.png",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "https://gitea.malxte.de/Bytemalte/bytemalte_de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Bytemalte Logo",
|
||||||
|
"description": "Das offizielle Bytemalte Logo Projekt.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Rust CLI Tool",
|
||||||
|
"description": "Ein nützliches Kommandozeilenwerkzeug in Rust.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "SQLite Datenbank Manager",
|
||||||
|
"description": "Verwaltungstool für lokale Datenbanken.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "Leptos Dashboard",
|
||||||
|
"description": "Ein schickes Dashboard für Server-Metriken.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "WASM Game Engine",
|
||||||
|
"description": "Spieleentwicklung direkt für den Browser.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"title": "Rust Auth System",
|
||||||
|
"description": "Sichere Benutzerauthentifizierung mit JWT.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"title": "Markdown Parser",
|
||||||
|
"description": "Ein schneller Markdown-zu-HTML Konverter.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"title": "Discord Bot in Rust",
|
||||||
|
"description": "Ein moderner Bot mit Serenity.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"title": "Gitea API Client",
|
||||||
|
"description": "Automatisierung für deine Repositories.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"title": "Rust Web Scraper",
|
||||||
|
"description": "Daten effizient aus dem Web extrahieren.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"title": "Chat App Backend",
|
||||||
|
"description": "Skalierbares Backend mit WebSockets.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"title": "Portfolio Template",
|
||||||
|
"description": "Ein minimalistisches Design für Entwickler.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"title": "Rust Network Tool",
|
||||||
|
"description": "Analyse von Netzwerkpaketen in Echtzeit.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"title": "Image Optimizer",
|
||||||
|
"description": "Bilder verlustfrei komprimieren mit Rust.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"title": "Rust Cryptography",
|
||||||
|
"description": "Implementierung moderner Verschlüsselung.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"title": "Server Monitor",
|
||||||
|
"description": "Uptime-Tracking für deine VPS.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"title": "Rust Compiler Plugin",
|
||||||
|
"description": "Erweiterung der Rust-Sprachfunktionen.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"title": "Desktop App mit Tauri",
|
||||||
|
"description": "Cross-Platform App mit Rust Backend.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"title": "Bytemalte Blog",
|
||||||
|
"description": "Ein statischer Blog-Generator.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"title": "Finales Testprojekt",
|
||||||
|
"description": "Das letzte Projekt in der Liste.",
|
||||||
|
"image_url": "/assets/Logo.jpg",
|
||||||
|
"download_url": "#",
|
||||||
|
"code_url": "#"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -9,18 +9,32 @@ pub struct ChatMessage {
|
|||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn login_or_register(user: String, pass: String) -> Result<i64, ServerFnError> {
|
pub async fn login_or_register(user: String, pass: String) -> Result<i64, ServerFnError> {
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
let pool = use_context::<SqlitePool>().unwrap();
|
let pool = use_context::<SqlitePool>().unwrap();
|
||||||
let existing = sqlx::query!("SELECT id, password_hash FROM users WHERE username = ?", user).fetch_optional(&pool).await?;
|
let existing = sqlx::query!(
|
||||||
|
"SELECT id, password_hash FROM users WHERE username = ?",
|
||||||
|
user
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if let Some(u) = existing {
|
if let Some(u) = existing {
|
||||||
if verify(&pass, &u.password_hash).unwrap_or(false) { Ok(u.id.expect("ID fehlt")) }
|
if verify(&pass, &u.password_hash).unwrap_or(false) {
|
||||||
else { Err(ServerFnError::new("Falsches Passwort")) }
|
Ok(u.id.expect("ID fehlt"))
|
||||||
|
} else {
|
||||||
|
Err(ServerFnError::new("Falsches Passwort"))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let hashed = hash(&pass, DEFAULT_COST).unwrap();
|
let hashed = hash(&pass, DEFAULT_COST).unwrap();
|
||||||
let id = sqlx::query!("INSERT INTO users (username, password_hash) VALUES (?, ?)", user, hashed)
|
let id = sqlx::query!(
|
||||||
.execute(&pool).await?.last_insert_rowid();
|
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||||
|
user,
|
||||||
|
hashed
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?
|
||||||
|
.last_insert_rowid();
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +42,13 @@ pub async fn login_or_register(user: String, pass: String) -> Result<i64, Server
|
|||||||
#[server]
|
#[server]
|
||||||
pub async fn send_message(user_id: i64, content: String) -> Result<(), ServerFnError> {
|
pub async fn send_message(user_id: i64, content: String) -> Result<(), ServerFnError> {
|
||||||
let pool = use_context::<sqlx::SqlitePool>().unwrap();
|
let pool = use_context::<sqlx::SqlitePool>().unwrap();
|
||||||
sqlx::query!("INSERT INTO messages (user_id, content) VALUES (?, ?)", user_id, content).execute(&pool).await?;
|
sqlx::query!(
|
||||||
|
"INSERT INTO messages (user_id, content) VALUES (?, ?)",
|
||||||
|
user_id,
|
||||||
|
content
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +56,30 @@ pub async fn send_message(user_id: i64, content: String) -> Result<(), ServerFnE
|
|||||||
pub async fn get_all_messages() -> Result<Vec<ChatMessage>, ServerFnError> {
|
pub async fn get_all_messages() -> Result<Vec<ChatMessage>, ServerFnError> {
|
||||||
let pool = use_context::<sqlx::SqlitePool>().unwrap();
|
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?;
|
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())
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ChatMessage {
|
||||||
|
username: r.username,
|
||||||
|
content: r.content,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn get_server_time() -> Result<String, ServerFnError> {
|
pub async fn get_server_time() -> Result<String, ServerFnError> {
|
||||||
Ok(chrono::Local::now().to_rfc3339())
|
Ok(chrono::Local::now().to_rfc3339())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_projects_from_file() -> Result<Vec<crate::pages::home::Project>, ServerFnError> {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let data = fs::read_to_string("projects.json")
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Konnte projects.json nicht laden: {}", e)))?;
|
||||||
|
|
||||||
|
// Hier nutzen wir leptos::serde_json
|
||||||
|
let projects: Vec<crate::pages::home::Project> = leptos::serde_json::from_str(&data)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("JSON Struktur ungültig: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(projects)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
use crate::backend::logic::get_projects_from_file;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::html::Div;
|
use leptos::wasm_bindgen::prelude::Closure; // Import direkt von leptos
|
||||||
use leptos::wasm_bindgen::JsCast;
|
use leptos::wasm_bindgen::JsCast;
|
||||||
use web_sys::HtmlElement;
|
use web_sys::{IntersectionObserver, IntersectionObserverEntry};
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
@@ -13,100 +14,62 @@ pub struct Project {
|
|||||||
pub code_url: String,
|
pub code_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_all_projects() -> Vec<Project> {
|
#[component]
|
||||||
vec![
|
fn ProjectCard(p: Project) -> impl IntoView {
|
||||||
Project {
|
let card_ref = NodeRef::<leptos::html::Div>::new();
|
||||||
id: 1,
|
let (is_visible, set_is_visible) = signal(false);
|
||||||
title: "Malxte.de Persönliche Rust Website".into(),
|
|
||||||
description: "Meine persönliche Website kommplett mit Rust und Yew Programmiert, alle Links zu Kanälen etc.".into(),
|
// Dieser Effekt aktiviert die Animation, sobald die Karte im Sichtfeld erscheint
|
||||||
image_url: "/assets/malxte_de.png".into(),
|
Effect::new(move |_| {
|
||||||
download_url: "#".into(),
|
if let Some(el) = card_ref.get() {
|
||||||
code_url: "https://gitea.malxte.de/Bytemalte/malxte_de".into(),
|
// Nutzung der Closure über den korrekten Pfad
|
||||||
},
|
let callback = Closure::wrap(Box::new(
|
||||||
Project {
|
move |entries: js_sys::Array, observer: IntersectionObserver| {
|
||||||
id: 2,
|
for entry in entries.to_vec() {
|
||||||
title: "Bytemalte.de Rust Community Website".into(),
|
let entry: IntersectionObserverEntry = entry.unchecked_into();
|
||||||
description: "Eine in Rust und mit Leptos Programmierte Bytemalte Community Website auf der du gerade bist :)".into(),
|
if entry.is_intersecting() {
|
||||||
image_url: "/assets/bytemalte_de.png".into(),
|
set_is_visible.set(true);
|
||||||
download_url: "#".into(),
|
// Beobachtung stoppen, sobald die Animation einmal getriggert wurde
|
||||||
code_url: "https://gitea.malxte.de/Bytemalte/bytemalte_de".into(),
|
observer.unobserve(&entry.target());
|
||||||
},
|
}
|
||||||
Project {
|
}
|
||||||
id: 3,
|
},
|
||||||
title: "Bytemalte Log".into(),
|
)
|
||||||
description: "Das ist das Offizielle Bytemalte Logo. (NICHT FREI VERWENDBAR!)".into(),
|
as Box<dyn FnMut(js_sys::Array, IntersectionObserver)>);
|
||||||
image_url: "/assets/Logo.jpg".into(),
|
|
||||||
download_url: "#".into(),
|
let observer = IntersectionObserver::new(callback.as_ref().unchecked_ref()).unwrap();
|
||||||
code_url: "#".into(),
|
observer.observe(&el);
|
||||||
},
|
callback.forget();
|
||||||
// Füge hier weitere Projekte hinzu...
|
}
|
||||||
]
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
node_ref=card_ref
|
||||||
|
class="project-card"
|
||||||
|
class:animate-in=move || is_visible.get()
|
||||||
|
>
|
||||||
|
<div class="project-image">
|
||||||
|
<img src=p.image_url alt=p.title.clone() />
|
||||||
|
</div>
|
||||||
|
<div class="project-content">
|
||||||
|
<h3>{p.title}</h3>
|
||||||
|
<p>{p.description}</p>
|
||||||
|
<div class="project-links">
|
||||||
|
<a href=p.download_url target="_blank" rel="noopener noreferrer" class="btn-download">"Download"</a>
|
||||||
|
<a href=p.code_url target="_blank" rel="noopener noreferrer" class="btn-code">"Source Code"</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn HomePage() -> impl IntoView {
|
pub fn HomePage() -> impl IntoView {
|
||||||
let items_per_page = 3;
|
let projects_resource = Resource::new(
|
||||||
let all_data = get_all_projects();
|
|| (),
|
||||||
|
|_| async move { get_projects_from_file().await.unwrap_or_default() },
|
||||||
// Initialer Zustand für SSR & Client
|
);
|
||||||
let initial_projects = all_data.iter().take(items_per_page).cloned().collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let (projects, set_projects) = signal(initial_projects);
|
|
||||||
let (page, set_page) = signal(1);
|
|
||||||
let (loading, set_loading) = signal(false);
|
|
||||||
let (all_loaded, set_all_loaded) = signal(all_data.len() <= items_per_page);
|
|
||||||
|
|
||||||
let sentinel = NodeRef::<Div>::new();
|
|
||||||
|
|
||||||
let fetch_projects = move |p: usize| {
|
|
||||||
if all_loaded.get_untracked() || loading.get_untracked() { return; }
|
|
||||||
|
|
||||||
set_loading.set(true);
|
|
||||||
let all_my_projects = get_all_projects();
|
|
||||||
let start = p * items_per_page;
|
|
||||||
let end = (start + items_per_page).min(all_my_projects.len());
|
|
||||||
|
|
||||||
if start >= all_my_projects.len() {
|
|
||||||
set_all_loaded.set(true);
|
|
||||||
set_loading.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kurze Verzögerung für den Lade-Effekt
|
|
||||||
set_timeout(move || {
|
|
||||||
let slice = all_my_projects[start..end].to_vec();
|
|
||||||
set_projects.update(|projs| projs.extend(slice));
|
|
||||||
|
|
||||||
if end >= all_my_projects.len() {
|
|
||||||
set_all_loaded.set(true);
|
|
||||||
}
|
|
||||||
set_loading.set(false);
|
|
||||||
}, std::time::Duration::from_millis(200));
|
|
||||||
};
|
|
||||||
|
|
||||||
Effect::new(move |_| {
|
|
||||||
let w = window();
|
|
||||||
let w_clone = w.clone();
|
|
||||||
|
|
||||||
let handle = gloo_events::EventListener::new(&w, "scroll", move |_| {
|
|
||||||
if all_loaded.get_untracked() || loading.get_untracked() { return; }
|
|
||||||
|
|
||||||
if let Some(doc) = w_clone.document() {
|
|
||||||
if let Some(el) = doc.document_element().and_then(|e| e.dyn_into::<HtmlElement>().ok()) {
|
|
||||||
let scroll_y = w_clone.scroll_y().unwrap_or(0.0);
|
|
||||||
let inner_height = w_clone.inner_height().unwrap().as_f64().unwrap_or(0.0);
|
|
||||||
let total_height = el.offset_height() as f64;
|
|
||||||
|
|
||||||
if scroll_y + inner_height >= total_height - 300.0 {
|
|
||||||
let current_page = page.get_untracked();
|
|
||||||
set_page.set(current_page + 1);
|
|
||||||
fetch_projects(current_page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
handle.forget();
|
|
||||||
});
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
@@ -115,38 +78,27 @@ pub fn HomePage() -> impl IntoView {
|
|||||||
<p>"Entdecke meine neuesten Projekte."</p>
|
<p>"Entdecke meine neuesten Projekte."</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
// Der Container MUSS die Klasse projects-grid haben
|
<Suspense fallback=move || view! { <div class="spinner"></div> }>
|
||||||
<div class="projects-grid">
|
<div class="projects-grid">
|
||||||
<For
|
{move || {
|
||||||
each=move || projects.get()
|
projects_resource.get().map(|all_projects| {
|
||||||
key=|p| p.id
|
view! {
|
||||||
children=|p| {
|
<For
|
||||||
view! {
|
each=move || all_projects.clone()
|
||||||
<div class="project-card">
|
key=|p| p.id
|
||||||
<div class="project-image">
|
children=|p| {
|
||||||
<img src=p.image_url alt=p.title.clone() />
|
view! { <ProjectCard p=p /> }
|
||||||
</div>
|
}
|
||||||
<div class="project-content">
|
/>
|
||||||
<h3>{p.title}</h3>
|
}
|
||||||
<p>{p.description}</p>
|
})
|
||||||
<div class="project-links">
|
}}
|
||||||
<a href=p.download_url target="_blank" rel="noopener noreferrer" class="btn-download">"Download"</a>
|
</div>
|
||||||
<a href=p.code_url target="_blank" rel="noopener noreferrer" class="btn-code">"Source Code"</a>
|
</Suspense>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div node_ref=sentinel class="loading-trigger">
|
<footer class="home-footer">
|
||||||
{move || match (loading.get(), all_loaded.get()) {
|
<p>"Das sind alle meine Projekte ✨"</p>
|
||||||
(true, _) => view! { <div class="spinner"></div> }.into_any(),
|
</footer>
|
||||||
(_, true) => view! { <p class="end-msg">"Das waren alle Projekte ✨"</p> }.into_any(),
|
|
||||||
_ => view! { <p>"Scrollen für mehr"</p> }.into_any(),
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
200
style/main.scss
200
style/main.scss
@@ -51,10 +51,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
border-radius: 1.5rem;
|
border-radius: 1.5rem;
|
||||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
|
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -64,15 +64,15 @@ body {
|
|||||||
|
|
||||||
// Hier stylen wir die Form-Inhalte direkt
|
// Hier stylen wir die Form-Inhalte direkt
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box; // Wichtig für korrektes Padding
|
box-sizing: border-box; // Wichtig für korrektes Padding
|
||||||
padding: 0.8rem 1rem;
|
padding: 0.8rem 1rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
@@ -81,14 +81,14 @@ body {
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
&:hover { background: var(--primary-dark); }
|
&:hover { background: var(--primary-dark); }
|
||||||
}
|
}
|
||||||
@@ -97,15 +97,15 @@ body {
|
|||||||
|
|
||||||
// --- CHAT WINDOW ---
|
// --- CHAT WINDOW ---
|
||||||
.chat-main {
|
.chat-main {
|
||||||
background: var(--white); width: 100%; max-width: 800px; height: 650px; border-radius: 1.5rem;
|
background: var(--white); width: 100%; max-width: 800px; height: 650px; border-radius: 1.5rem;
|
||||||
display: flex; flex-direction: column; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.1); overflow: hidden;
|
display: flex; flex-direction: column; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.1); overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-top-bar {
|
.chat-top-bar {
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
h3 { margin: 0; font-size: 1.1rem; }
|
h3 { margin: 0; font-size: 1.1rem; }
|
||||||
@@ -139,7 +139,7 @@ body {
|
|||||||
|
|
||||||
// --- FOOTER & SERVER TIME ---
|
// --- FOOTER & SERVER TIME ---
|
||||||
.app-footer {
|
.app-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem 0;
|
padding: 3rem 0;
|
||||||
.server-time {
|
.server-time {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -271,4 +271,160 @@ body {
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
// Variablen für dein Design
|
||||||
|
$card-bg: #1e1e1e;
|
||||||
|
$text-primary: #ffffff;
|
||||||
|
$text-secondary: #b3b3b3;
|
||||||
|
$accent-color: #f74c00; // Dein Rust/Bytemalte Orange
|
||||||
|
$card-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
$transition-speed: 0.6s;
|
||||||
|
|
||||||
|
.home-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Grid-Layout
|
||||||
|
.projects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Projekt-Karte
|
||||||
|
.project-card {
|
||||||
|
background: $card-bg;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: $card-shadow;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// --- ANIMATIONS-LOGIK ---
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(40px);
|
||||||
|
transition: opacity $transition-speed cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||||
|
transform $transition-speed cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
will-change: opacity, transform;
|
||||||
|
|
||||||
|
&.animate-in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
|
||||||
|
.project-image img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-content {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: filter 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-download {
|
||||||
|
background: $accent-color;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-code {
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
color: $text-secondary;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lade-Spinner Styling
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-left-color: $accent-color;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 40px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user