30 Commits
Author SHA1 Message Date
schn33fuchs 7b26155118 README change 2026-06-07 09:39:09 +02:00
schn33fuchs a653bf3f45 README 2026-05-29 10:05:20 +02:00
schn33fuchs fb30d4ab83 Documentation
The docs now include dark mode, the sidebar on mobile and a expanded
ticket lifecycle
2026-05-29 10:03:58 +02:00
schn33fuchs 26384d2849 Sidebar mobile improvement
The sidebar vanished on a too small display. Button to reopen and close
are rendered
2026-05-29 09:45:34 +02:00
schn33fuchs bec2a8a451 Style fix
Login block now centered on page instead of same place as  with sidebar
2026-05-29 09:37:25 +02:00
schn33fuchs f96db06a33 Better error message
German now
2026-05-29 09:36:56 +02:00
schn33fuchs 6ef50d06aa Error fixes
type mismatch in update user and some styling improvements
2026-05-28 18:49:29 +02:00
schn33fuchs 04b9ef8f9e Merge pull request 'darkmode' (#41) from darkmode into styles-nino
Reviewed-on: #41
2026-05-28 17:29:23 +02:00
schn33fuchs 778c112dd4 Home button fixed
It is now an icon in the middle of the button instead of a unicode
number
2026-05-28 17:27:36 +02:00
schn33fuchs b9354f77b6 Darkmode implemented
There is now a darkmode button
2026-05-28 17:26:56 +02:00
schn33fuchs 6e836d9260 Some styling 2026-05-26 22:01:50 +02:00
schn33fuchs e096b9144b Final diagram fix
for real this time
2026-05-26 20:47:25 +02:00
schn33fuchs 74a573e38e Diagram fix final 2026-05-26 20:46:48 +02:00
schn33fuchs 7f8afe1938 Rename 2026-05-26 20:34:20 +02:00
schn33fuchs e070d36af9 Diagram fix
hopefully
2026-05-26 20:31:58 +02:00
schn33fuchs d3d0e9b83c Cleanup 2026-05-26 20:29:34 +02:00
schn33fuchs 3000bb0e5d Translation
Everything is now German
2026-05-26 20:20:36 +02:00
schn33fuchs e2cfb61caa Cleanup 2026-05-25 17:43:07 +02:00
schn33fuchs d1576ae8fa For real real 2026-05-22 15:14:16 +02:00
schn33fuchs 8287bea240 Last one for real 2026-05-22 15:13:47 +02:00
schn33fuchs 663f61fa34 Sequence diagram fix
Hopefully the last
2026-05-22 15:11:15 +02:00
schn33fuchs 50c79231bb More readme fixes 2026-05-22 15:09:28 +02:00
schn33fuchs 6950ec1c36 Another fix 2026-05-22 15:06:31 +02:00
schn33fuchs 9402344f77 Diagram update
Sequence diagram fixed
2026-05-22 14:47:03 +02:00
schn33fuchs 2b9aa03932 Sequence diagrams
Generated by antigravity
2026-05-22 14:40:23 +02:00
schn33fuchs 42f2d33a58 Direction 2026-05-22 14:16:38 +02:00
schn33fuchs 8de96aea11 Class diagram 2026-05-22 14:10:13 +02:00
schn33fuchs edb19c5569 README 2026-05-22 14:08:52 +02:00
schn33fuchs a75de3dbef README update 2026-05-22 14:04:45 +02:00
schn33fuchs d27a76ccc7 README update
important note added
2026-05-20 21:11:55 +02:00
26 changed files with 1058 additions and 298 deletions
+1
View File
@@ -21,6 +21,7 @@ frontend/node_modules/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
.antigravitycli/
# Added by cargo
+63
View File
@@ -0,0 +1,63 @@
# Ticketsystem
A ticket system with backend and frontend components.
## Components
- **[Backend](../backend/index.html)** - The server-side API and business logic
- **[Frontend](../frontend/index.html)** - The client-side user interface
## Usage
### Prerequisite
#### IMPORTANT
Before compiling the programm you have to install the rust toolchain.
For a guide to do this, visit: <https://rust-lang.org/tools/install/>
A instance of a postgresql has to be accessible to the backend. Place the connection details in a .env file into the variable `DATABASE_URL`
To setup the tables you either can create them manually by following the sheme specified in `backend/migrations` or apply them with sqlx
To install sqlx run `cargo install sqlx` and then in the `backend` directory run `sqlx migrate run` to create the tables
### Environment
The .env file has to be in the root directory of the project or in the same directory as the executable
Keys:
`DATABASE_URL`: Specifying the url and connection details for the database
`TOKEN_SECRET`: The JWT token secret, can theoretically be anything but is more secure when generated with a tool, e.g: <https://jwtsecrets.com/>
`ORIGIN`: The origin of the frontend, used for CORS rules
`BACKEND_PORT`: The port which the backend should use to run on
### Backend
The backend can either be run via `cargo run --release` or `cargo build --release` using the correct target architecture, e.g. 'x86_64-unknown-linux-gnu',
the executable will be placed in the `target/release` directory and can then be run via any method
### Frontend
The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting wasm
#### NOTE
To install trunk run `cargo install trunk`
#### IMPORTANT
Requests from the frontend to /api/* have to be proxied to the Backend
Example with nginx and frontend running at localhost:8000 and backend at localhost:9000 :
```nginx
location /api/ {
proxy_pass http://localhost:9000/api;
}
```
## Usage of AI
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation
Google Antigravity generated the Sequence Diagrams
### Prompt
Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc.
Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output
The comments with `///` or `//!`
I've gone over it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+330 -4
View File
@@ -4,8 +4,8 @@ A ticket system with backend and frontend components.
## Components
- **[Backend](../backend/index.html)** - The server-side API and business logic
- **[Frontend](../frontend/index.html)** - The client-side user interface
- **[Backend]** - The server-side API and business logic
- **[Frontend]** - The client-side user interface
## Usage
### Prerequisite
@@ -44,16 +44,342 @@ The HTML code for the frontend can be generated by using `trunk build`. The resu
> }
> ```
## Diagrams
### Class Diagramm
#### Backend
```mermaid
classDiagram
class Error {
+status: &'static str
+message: String
}
class TicketResponse {
+id: i32
+category: String
+betreff: String
+description: String
+room: i16
+status: String
+date: chrono::DateTime~chrono::Utc~
+user_id: i16
+user_first_name: String
+user_last_name: String
}
class User {
+id: i16
+last_name: String
+first_name: String
+username: String
+is_admin: bool
+pwd: String
}
class TicketCreateScheme {
+category: String
+betreff: String
+description: String
+room: i16
}
class TicketUpdateScheme {
+status: String
}
class UserUpdateScheme {
+id: i16
+first_name: String
+last_name: String
+username: String
+make_admin: bool
+new_pwd: String
}
class UserCreateScheme {
+first_name: String
+last_name: String
+username: String
+is_admin: bool
+pwd: String
}
class LoginScheme {
+username: String
+pwd: String
}
class FilteredUser {
+id: i16
+first_name: String
+last_name: String
+username: String
+is_admin: bool
}
class Claims {
+sub: String
+issued: usize
+expires: usize
}
class AppState {
-db: PgPool
-env: Env
}
class Env {
+db_url: String
+token_secret: String
+origin: String
+backend_port: String
+load() Env
}
AppState --> Env
Env ..> Env
```
#### Frontend
```mermaid
classDiagram
class TicketCreateScheme {
+category: String
+betreff: String
+description: String
+room: i16
}
class TicketUpdateScheme {
+status: String
}
class Ticket {
+id: i32
+category: String
+betreff: String
+description: String
+room: i16
+status: String
+date: chrono::DateTime~chrono::Utc~
+user_id: i16
+user_first_name: String
+user_last_name: String
}
class TicketProps {
+id: i32
}
class ActiveUser {
+id: Option~i16~
+is_admin: bool
}
class ApiError {
-message: String
-_status: String
}
class SidebarExpandState {
+ticket_open: bool
+users_open: bool
}
class Default {
+default() Self
}
class SidebarState {
+expand: SidebarExpandState
+set_tickets_open: Callback~bool~
+toggle_tickets: Callback~()~
+set_users_open: Callback~bool~
+toggle_users: Callback~()~
+new(expand:SidebarExpandState, set_tickets_open:Callback~bool~, toggle_tickets:Callback~()~, set_users_open:Callback~bool~, toggle_users:Callback~()~) Self
}
class SidebarProps {
+children: Children
}
class TicketPartial {
-date: DateTime~Utc~
-room: i16
-user_id: i16
}
class UserPartial {
-id: i16
-first_name: String
-last_name: String
}
class RoomTotalsProps {
-tickets: Vec~TicketPartial~
}
class UserTotalProps {
-users: Vec~UserPartial~
-tickets: Vec~TicketPartial~
}
class AdminSetupScheme {
+first_name: String
+last_name: String
+username: String
+pwd: String
}
class UserCreateScheme {
+first_name: String
+last_name: String
+username: String
+is_admin: bool
+pwd: String
}
class LoginScheme {
+username: String
+pwd: String
}
class UserUpdateScheme {
+id: i16
+first_name: String
+last_name: String
+username: String
+make_admin: bool
+new_pwd: String
}
class FilteredUser {
+id: i16
+first_name: String
+last_name: String
+username: String
+is_admin: bool
}
class UserProps {
+id: i16
}
class ApiError {
-message: String
-_status: String
}
class AuthState {
+is_authenticated: Option~bool~
+is_admin: Option~bool~
}
class ProtectedRouteProps {
+children: Children
+admin_page: bool
}
class SidebarShellProps {
+children: Children
}
class SidebarComponentProps {
+is_open: bool
+on_close: Callback~()~
}
class AdminCheckWrapperProps {
+children: Children
}
SidebarState --> SidebarExpandState
RoomTotalsProps --> TicketPartial
UserTotalProps --> UserPartial
UserTotalProps --> TicketPartial
SidebarShellProps ..> SidebarComponentProps
```
### Sequence Diagrams
#### 1. System Initialization & Administrator Setup
```mermaid
sequenceDiagram
autonumber
actor Admin as Initial Administrator
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB@{"type": "database", "alias": "Database"}
Note over Admin, DB: System Initialization Flow
FE->>BE: GET /api/check-admin
BE->>DB: SELECT COUNT(*) FROM users WHERE is_admin = true
DB-->>BE: 0 (No admin found)
BE-->>FE: HTTP 200 OK {"exists": false}
FE-->>Admin: Render Admin Setup Page
Admin->>FE: Input username, password, first/last name
FE->>BE: POST /api/setup-admin {username, pwd, ...}
Note over BE: Hash password using Argon2
BE->>DB: INSERT INTO users (username, pwd, is_admin, ...)
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Redirect to Login Page
```
#### 2. User Authentication Flow (Login)
```mermaid
sequenceDiagram
autonumber
actor User
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB as Database@{"type": "database"}
Note over User, DB: Authentication & Cookie Session Setup
User->>FE: Enter username & password
FE->>BE: POST /api/login {username, pwd}
BE->>DB: SELECT * FROM users WHERE username = $1
DB-->>BE: Return user record with password hash
Note over BE: Verify password using Argon2
alt Password Valid
Note over BE: Generate JWT token containing claims (sub: user_id)
Note over BE: Build HttpOnly, Secure, Lax cookie 'token'
BE-->>FE: HTTP 200 OK {"status": "success", "token": "...", "user": {...}}
Note over BE,FE: Header: Set-Cookie: token=...#59; Path=/#59; HttpOnly#59; SameSite=Lax
Note over FE: Save auth state to global context
FE-->>User: Redirect to Dashboard / Home
else Password Invalid
BE-->>FE: HTTP 400 Bad Request {"status": "error", "message": "Invalid password"}
FE-->>User: Display error message
end
```
#### 3. Ticket Lifecycle Flow
```mermaid
sequenceDiagram
autonumber
actor User as Authenticated User
actor Admin as Administrator
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB as Database@{"type": "database"}
Note over User, DB: Ticket Creation Flow (Protected Route)
User->>FE: Fill out ticket form & submit
FE->>BE: POST /api/tickets/create {category, betreff, description, room} (Includes 'token' cookie)
Note over BE: validate_token middleware decodes & verifies JWT
BE->>DB: INSERT INTO tickets (category, description, betreff, room, user_id)
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>User: Clear form & display success notification
Note over Admin, DB: Ticket Review & Resolution (Admin Only Route)
Admin->>FE: View Ticket Board
FE->>BE: GET /api/tickets (Includes 'token' cookie)
Note over BE: validate_token middleware checks JWT
BE->>DB: SELECT tickets JOIN users ...
DB-->>BE: Return list of tickets
BE-->>FE: HTTP 200 OK [tickets]
FE-->>Admin: Render Ticket List
Admin->>FE: Click "Resolve" on ticket
FE->>BE: PATCH /api/tickets/{id} {"status": "Resolved"} (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & checks is_admin = true
BE->>DB: UPDATE tickets SET status = $1 WHERE id = $2
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Update ticket status in UI
Note over Admin, DB: Ticket Archiving Flow (Admin Only Route)
Admin->>FE: Open Archived Tickets page
FE->>BE: GET /api/tickets/archive (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & admin role
BE->>DB: SELECT * FROM tickets WHERE status = 'Archived'
DB-->>BE: Return archived tickets
BE-->>FE: HTTP 200 OK [Archived Tickets]
FE-->>Admin: Render historical archive board
```
## Usage of AI
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation:
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation
Google Antigravity generated the Sequence Diagrams
### Prompt
Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc.
Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output
The comments with `///` or `//!`
I've gone over most of it and modified it to my needs and opinions
I've gone over it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+2 -2
View File
@@ -43,7 +43,7 @@ pub fn encode_token(header: &Header, id: String, key: &EncodingKey) -> String {
expires: expires as usize,
};
let token = encode(header, &claims, key);
return token.expect("token return failed");
token.expect("token return failed")
}
/// Decodes and validates a JSON Web Token (JWT).
@@ -77,5 +77,5 @@ pub fn decode_token(token: String, key: &DecodingKey) -> Result<Claims, (StatusC
(StatusCode::UNAUTHORIZED, Json(error))
})?
.claims;
return Ok(claims);
Ok(claims)
}
+4 -16
View File
@@ -48,13 +48,7 @@ pub async fn validate_token(
.headers()
.get(header::AUTHORIZATION)
.and_then(|header| header.to_str().ok())
.and_then(|value| {
if value.starts_with("Bearer ") {
Some(value[7..].to_owned())
} else {
None
}
})
.and_then(|value| value.strip_prefix("Bearer ").map(|s| s.to_owned()))
});
let token = token.ok_or_else(|| {
@@ -77,7 +71,7 @@ pub async fn validate_token(
(status, Json(error))
})?;
let uuid = (&claims.sub).parse::<i16>().map_err(|_| {
let uuid = claims.sub.parse::<i16>().map_err(|_| {
let error = json!({
"status": "error",
"message": "Invalid user id"
@@ -143,13 +137,7 @@ pub async fn validate_admin(
.headers()
.get(header::AUTHORIZATION)
.and_then(|header| header.to_str().ok())
.and_then(|value| {
if value.starts_with("Bearer ") {
Some(value[7..].to_owned())
} else {
None
}
})
.and_then(|value| value.strip_prefix("Bearer ").map(|s| s.to_owned()))
});
let token = token.ok_or_else(|| {
@@ -172,7 +160,7 @@ pub async fn validate_admin(
(status, Json(error))
})?;
let uuid = (&claims.sub).parse::<i16>().map_err(|_| {
let uuid = claims.sub.parse::<i16>().map_err(|_| {
let error = json!({
"status": "error",
"message": "Invalid user id"
+20 -25
View File
@@ -60,7 +60,7 @@ pub async fn create_user(
)
})?;
if let Some(_) = exist_check {
if exist_check.is_some() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "user already exists"})),
@@ -90,10 +90,10 @@ pub async fn create_user(
})?;
if user.rows_affected() < 1 {
return Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": "Error creating user"})),
));
))
} else {
Ok(Json(json!({"status": "success", "result": "User created"})))
}
@@ -145,19 +145,19 @@ pub async fn login(
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "Invalid username"})),
Json(json!({"status": "error", "message": "Ungültiger Benutzername"})),
)
})?;
let pwd_hash = PasswordHash::new(&user.pwd);
let valid_pwd = Argon2::default()
.verify_password(&request.pwd.as_bytes(), &pwd_hash.unwrap())
.verify_password(request.pwd.as_bytes(), &pwd_hash.unwrap())
.is_ok();
if !valid_pwd {
let error_response = serde_json::json!({
"status": "error",
"message": "Invalid password"
"message": "Ungültiges passwort"
});
return Err((StatusCode::BAD_REQUEST, Json(error_response)));
}
@@ -268,7 +268,7 @@ pub async fn get_current_user(
/// - `404 Not Found` if user doesn't exist
/// - `500 Internal Server Error` if database error occurs
pub async fn delete_user(
Path(id): Path<i32>,
Path(id): Path<i16>,
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#)
@@ -338,10 +338,7 @@ pub async fn get_users(
(StatusCode::INTERNAL_SERVER_ERROR, Json(error))
})?;
let response = users
.iter()
.map(|user| filter_user(&user))
.collect::<Vec<FilteredUser>>();
let response = users.iter().map(filter_user).collect::<Vec<FilteredUser>>();
let json_respnse = json!(response);
Ok(Json(json_respnse))
}
@@ -372,22 +369,20 @@ pub async fn get_user_by_id(
match query {
Ok(user) => {
let response = serde_json::json!(filter_user(&user));
return Ok(Json(response));
Ok(Json(response))
}
Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("User with ID {} not found", id)
});
return Err((StatusCode::NOT_FOUND, Json(error_response)));
Err((StatusCode::NOT_FOUND, Json(error_response)))
}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
));
}
};
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)),
}
}
/// Updates an existing user's information.
@@ -409,9 +404,9 @@ pub async fn get_user_by_id(
/// # Security Note
/// - Passwords are hashed using Argon2 before storage.
/// - This endpoint requires admin privileges (enforced by middleware via
/// [`validate_admin`](crate::cookie::validation::validate_admin)).
/// [`validate_admin`](crate::cookie::validation::validate_admin)).
pub async fn update_user(
Path(id): Path<i32>,
Path(id): Path<i16>,
State(data): State<Arc<AppState>>,
Json(body): Json<UserUpdateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
@@ -572,10 +567,10 @@ pub async fn setup_initial_admin(
})?;
if user.rows_affected() < 1 {
return Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": "Error creating admin user"})),
));
))
} else {
Ok(Json(
json!({"status": "success", "result": "Admin user created"}),
@@ -616,6 +611,6 @@ pub fn filter_user(user: &User) -> FilteredUser {
first_name: user.first_name.clone(),
last_name: user.last_name.clone(),
username: user.username.clone(),
is_admin: user.is_admin.clone(),
is_admin: user.is_admin,
}
}
+5 -5
View File
@@ -230,22 +230,22 @@ pub async fn get_ticket_by_id(
user_last_name: row.get("last_name"),
};
let response = serde_json::json!(ticket_response);
return Ok(Json(response));
Ok(Json(response))
}
Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("Ticket with ID {} not found", id)
});
return Err((StatusCode::NOT_FOUND, Json(error_response)));
Err((StatusCode::NOT_FOUND, Json(error_response)))
}
Err(e) => {
return Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
));
))
}
};
}
}
/// Updates a ticket's status.
+1 -1
View File
@@ -64,7 +64,7 @@ async fn main() {
let database_url = &env.db_url;
// Establish connection pool to PostgreSQL
let pool = match PgPoolOptions::new().connect(&database_url).await {
let pool = match PgPoolOptions::new().connect(database_url).await {
Ok(pool) => {
println!("Database connection successful");
pool
+1 -1
View File
@@ -8,7 +8,7 @@ services:
environment:
- POSTGRES_PASSWORD=tickets
volumes:
- pg_data:/var/lib/postregsql/pg_data
- pg_data:/var/lib/postgresql/pg_data
volumes:
pg_data:
+2 -1
View File
@@ -16,7 +16,8 @@ serde = { workspace = true }
wasm-bindgen-futures = "0.4.70"
web-sys = { version = "0.3.95", features = [
"Window","Document","Request","Response","Headers", "HtmlSelectElement", "RequestCredentials",
"SubmitEvent","InputEvent","HtmlInputElement","Event", "HtmlFormElement", "MouseEvent"
"SubmitEvent","InputEvent","HtmlInputElement","Event", "HtmlFormElement", "MouseEvent",
"Element", "MediaQueryList", "DomTokenList"
] }
gloo-net = "0.7.0"
gloo-storage = "0.4.0"
+2 -2
View File
@@ -109,7 +109,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
AuthState {
is_authenticated: None,
..
} => html! { <div>{ "Loading..." } </div> },
} => html! { <div>{ "Lade..." } </div> },
AuthState {
is_authenticated: Some(false),
..
@@ -126,7 +126,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
Some(false) => {
html! { <Redirect<crate::Route> to={crate::Route::PermissionDenied}/> }
}
None => html! { <div>{ "Checking permissions..." }</div> },
None => html! { <div>{ "Berechtigungen werden geprüft..." }</div> },
}
} else {
props.children.clone().into()
+74
View File
@@ -0,0 +1,74 @@
use gloo_storage::{LocalStorage, Storage};
use web_sys::window;
use yew::prelude::*;
/// A persistent, floating toggle button to switch the user interface theme between Dark Mode and Light Mode.
///
/// # Functionality
/// 1. **System Preference Check**: If the user hasn't explicitly set a preference, detects the operating system theme
/// preference using browser `match_media("(prefers-color-scheme: dark)")` query.
/// 2. **Session Persistence**: Caches the selected theme choice under the `"dark-mode"` key in the browser's `LocalStorage`
/// so that user preferences persist across page reloads.
/// 3. **Dynamic Style Swapping**: Injects or removes the `.theme-dark` / `.theme-light` classes directly on the document
/// `body` element, triggering the theme change via globally defined CSS variables.
#[function_component(DarkModeToggle)]
pub fn dark_mode_toggle() -> Html {
// 1. Initialize state from LocalStorage or system preference
let is_dark = use_state(|| {
LocalStorage::get::<bool>("dark-mode").unwrap_or_else(|_| {
// Fallback to system preference if not set
window()
.and_then(|w| w.match_media("(prefers-color-scheme: dark)").ok().flatten())
.map(|m| m.matches())
.unwrap_or(false)
})
});
// 2. Synchronize the class on the body when the state changes
{
let is_dark = is_dark.clone();
use_effect_with(is_dark, |is_dark| {
if let Some(win) = window() {
if let Some(doc) = win.document() {
if let Some(body) = doc.body() {
if **is_dark {
let _ = body.class_list().add_1("theme-dark");
let _ = body.class_list().remove_1("theme-light");
let _ = LocalStorage::set("dark-mode", true);
} else {
let _ = body.class_list().add_1("theme-light");
let _ = body.class_list().remove_1("theme-dark");
let _ = LocalStorage::set("dark-mode", false);
}
}
}
}
|| ()
});
}
// 3. Toggle action
let onclick = {
let is_dark = is_dark.clone();
Callback::from(move |_| {
is_dark.set(!*is_dark);
})
};
html! {
<button class="dark-mode-toggle" {onclick}>
if *is_dark {
// Sun Icon for switching to light mode
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
</svg>
} else {
// Moon Icon for switching to dark mode
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
}
</button>
}
}
+50 -4
View File
@@ -1,5 +1,6 @@
mod auth;
mod pages;
mod dark_mode;
use crate::auth::ProtectedRoute;
use crate::pages::*;
use gloo_net::http::Request;
@@ -69,8 +70,15 @@ pub struct SidebarShellProps {
/// This component is designed to wrap page-specific content, ensuring that the sidebar
/// is always present for navigation. Integrates with [`crate::pages::sidebar::Sidebar`] for navigation.
///
/// # Mobile Support
/// On mobile displays, the sidebar is hidden by default and can be toggled:
/// - **Menu Toggle Button**: Renders a floating menu button to slide the sidebar open.
/// - **Dark Mode Toggle**: Floating button to change theme.
/// - **Overlay Backdrop**: Dims the screen when the sidebar is open. Clicking it closes the sidebar.
/// - **Auto-Close on Navigation**: Automatically closes the sidebar when navigating to a new route.
///
/// # Components
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component.
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component, accepting mobile open state.
/// - Main content area: Renders the `children` passed to this component.
///
/// # Example
@@ -83,9 +91,46 @@ pub struct SidebarShellProps {
/// ```
#[component(SidebarShell)]
fn sidebar_shell(props: &SidebarShellProps) -> Html {
let route = use_route::<Route>();
let mobile_sidebar_open = use_state(|| false);
// Close mobile sidebar automatically on any route transition
{
let mobile_sidebar_open = mobile_sidebar_open.clone();
use_effect_with(route, move |_| {
mobile_sidebar_open.set(false);
|| ()
});
}
let on_open = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: MouseEvent| mobile_sidebar_open.set(true))
};
let on_close = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: ()| mobile_sidebar_open.set(false))
};
let on_close_click = {
let on_close = on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
};
html! {
<div class="layout">
<sidebar::Sidebar/>
<button class="mobile-menu-toggle" onclick={on_open}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12"/>
<line x1="4" x2="20" y1="6" y2="6"/>
<line x1="4" x2="20" y1="18" y2="18"/>
</svg>
</button>
<div class={if *mobile_sidebar_open { "sidebar-overlay open" } else { "sidebar-overlay" }} onclick={on_close_click}></div>
<sidebar::Sidebar is_open={*mobile_sidebar_open} on_close={on_close} />
<main class="content">
{ for props.children.iter() }
</main>
@@ -147,10 +192,10 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
}
match *admin_exists {
None => html! { <div>{ "Loading..." }</div> },
None => html! { <div>{ "Lade..." }</div> },
Some(false) => {
navigator.push(&Route::Setup);
html! { <div>{ "Redirecting to setup..." }</div> }
html! { <div>{ "Leite weiter zur Einrichtung..." }</div> }
}
Some(true) => props.children.clone().into(),
}
@@ -271,6 +316,7 @@ fn switch(route: Route) -> Html {
pub fn app() -> Html {
html! {
<BrowserRouter>
<dark_mode::DarkModeToggle />
<Switch<Route> render={switch} />
</BrowserRouter>
}
+8 -8
View File
@@ -72,7 +72,7 @@ pub fn home_component() -> Html {
);
name.set(name_value);
}
_ => name.set("Unknown".to_string()),
_ => name.set("Unbekannt".to_string()),
}
});
|| ()
@@ -82,11 +82,11 @@ pub fn home_component() -> Html {
html! {
<div class="form-container home">
<div class="page-header">
<h1>{ "Welcome" }</h1>
<h1>{ "Willkommen" }</h1>
</div>
<crate::utilities::TicketCount/>
<div>
<p>{ "You are logged in as: " }</p>
<p>{ "Sie sind angemeldet als: " }</p>
<p class="text-muted">{ &*name }</p>
</div>
</div>
@@ -106,13 +106,13 @@ pub fn home_component() -> Html {
/// ```
#[component(NotFound)]
pub fn not_found_component() -> Html {
let message = "404 Not found";
let message = "404 Nicht gefunden";
html! {
<div class="form-container">
<div class="empty-state">
<h1>{&message}</h1>
<p>{ "The page you are looking for does not exist." }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Back to Home" }</Link<crate::Route>>
<p>{ "Die von Ihnen gesuchte Seite existiert nicht." }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div>
</div>
}
@@ -135,10 +135,10 @@ pub fn denied_component() -> Html {
html! {
<div class="form-container">
<div class="empty-state">
<h1>{ "Access Denied" }</h1>
<h1>{ "Zugriff verweigert" }</h1>
<p>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</p>
<p class="text-muted">{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Back to Home" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div>
</div>
}
+20 -20
View File
@@ -67,7 +67,7 @@ pub fn initial_admin_setup() -> Html {
let username = use_state(|| "".to_string());
let pwd = use_state(|| "".to_string());
let pwd_confirm = use_state(|| "".to_string());
let error = use_state(|| String::new());
let error = use_state(String::new);
let success = use_state(|| false);
let loading = use_state(|| false);
let admin_check_done = use_state(|| false);
@@ -103,7 +103,7 @@ pub fn initial_admin_setup() -> Html {
}
if !*admin_check_done {
return html! { <div>{ "Checking..." }</div> };
return html! { <div>{ "Wird überprüft..." }</div> };
}
let onsubmit = {
@@ -121,17 +121,17 @@ pub fn initial_admin_setup() -> Html {
e.prevent_default();
if (*pwd).is_empty() || (*pwd_confirm).is_empty() {
error.set("Password fields cannot be empty".to_string());
error.set("Passwortfelder dürfen nicht leer sein".to_string());
return;
}
if *pwd != *pwd_confirm {
error.set("Passwords do not match".to_string());
error.set("Passwörter stimmen nicht überein".to_string());
return;
}
if (*username).is_empty() {
error.set("Username cannot be empty".to_string());
error.set("Benutzername darf nicht leer sein".to_string());
return;
}
@@ -175,7 +175,7 @@ pub fn initial_admin_setup() -> Html {
let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text));
}
Err(err) => error.set(format!("Network error: {}", err)),
Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
}
});
})
@@ -184,16 +184,16 @@ pub fn initial_admin_setup() -> Html {
html! {
<div class="setup-container">
<div class="setup-box">
<h1>{ "Initial Admin Setup" }</h1>
<p>{ "Create your first administrator account" }</p>
<h1>{ "Erstmalige Admin-Einrichtung" }</h1>
<p>{ "Erstellen Sie Ihr erstes Administrator-Konto" }</p>
<form {onsubmit} class="setup-form">
<div class="form-group">
<label for="first_name">{ "First Name:" }
<label for="first_name">{ "Vorname:" }
<input
id="first_name"
type="text"
placeholder="First name"
placeholder="Vorname"
value={(*first_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -204,11 +204,11 @@ pub fn initial_admin_setup() -> Html {
</div>
<div class="form-group">
<label for="last_name">{ "Last Name:" }
<label for="last_name">{ "Nachname:" }
<input
id="last_name"
type="text"
placeholder="Last name"
placeholder="Nachname"
value={(*last_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -219,11 +219,11 @@ pub fn initial_admin_setup() -> Html {
</div>
<div class="form-group">
<label for="username">{ "Username:" }
<label for="username">{ "Benutzername:" }
<input
id="username"
type="text"
placeholder="Username"
placeholder="Benutzername"
value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -234,11 +234,11 @@ pub fn initial_admin_setup() -> Html {
</div>
<div class="form-group">
<label for="password">{ "Password:" }
<label for="password">{ "Passwort:" }
<input
id="password"
type="password"
placeholder="Password"
placeholder="Passwort"
value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -249,11 +249,11 @@ pub fn initial_admin_setup() -> Html {
</div>
<div class="form-group">
<label for="pwd_confirm">{ "Confirm Password:" }
<label for="pwd_confirm">{ "Passwort bestätigen:" }
<input
id="pwd_confirm"
type="password"
placeholder="Confirm password"
placeholder="Passwort bestätigen"
value={(*pwd_confirm).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -264,7 +264,7 @@ pub fn initial_admin_setup() -> Html {
</div>
<button type="submit" disabled={*loading} class="submit-btn">
{ if *loading { "Creating..." } else { "Create Admin Account" } }
{ if *loading { "Wird erstellt..." } else { "Admin-Konto erstellen" } }
</button>
if !error.is_empty() {
@@ -272,7 +272,7 @@ pub fn initial_admin_setup() -> Html {
}
if *success {
<p class="success-message" style="color:green">{ "Admin account created successfully! Redirecting to login..." }</p>
<p class="success-message" style="color:green">{ "Admin-Konto erfolgreich erstellt! Weiterleitung zum Login..." }</p>
}
</form>
</div>
+101 -50
View File
@@ -133,7 +133,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: v,
users_open: (*state).users_open,
users_open: state.users_open,
})
})
};
@@ -141,10 +141,10 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let toggle_tickets = {
let state = state.clone();
Callback::from(move |_| {
let current = (*state).ticket_open;
let current = state.ticket_open;
state.set(SidebarExpandState {
ticket_open: !current,
users_open: (*state).users_open,
users_open: state.users_open,
});
})
};
@@ -153,7 +153,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let state = state.clone();
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: (*state).ticket_open,
ticket_open: state.ticket_open,
users_open: v,
})
})
@@ -162,9 +162,9 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let toggle_users = {
let state = state.clone();
Callback::from(move |_| {
let current = (*state).users_open;
let current = state.users_open;
state.set(SidebarExpandState {
ticket_open: (*state).ticket_open,
ticket_open: state.ticket_open,
users_open: !current,
});
})
@@ -234,10 +234,10 @@ pub fn ticket_menu() -> Html {
html! {
<ul class="submenu" role="menu">
<li role="none">
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Submit Ticket" }</span></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Ticket erstellen" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "View Tickets" }</span></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "Tickets anzeigen" }</span></Link<crate::Route>>
</li>
</ul>
}
@@ -289,7 +289,7 @@ pub fn users_menu() -> Html {
onclick={on_toggle}
aria-expanded={open.to_string()}
>
{ "Users" }
{ "Benutzer" }
{ if open { "" } else { "" } }
</button>
@@ -298,10 +298,10 @@ pub fn users_menu() -> Html {
html! {
<ul class="submenu" role="menu">
<li role="none">
<Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Create User" }</span></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Benutzer erstellen" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "View Users" }</span></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "Benutzer anzeigen" }</span></Link<crate::Route>>
</li>
</ul>
}
@@ -319,6 +319,11 @@ pub fn users_menu() -> Html {
/// and administrative status. It fetches the current user's details via `/api/users/current`
/// to determine what menu items to display.
///
/// # Mobile Support
/// On small screens:
/// - Slides into view from the left when `props.is_open` is `true`.
/// - Renders a close button (`✕`) in the header that emits `props.on_close`.
///
/// # Structure
/// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state.
/// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items.
@@ -337,8 +342,22 @@ pub fn users_menu() -> Html {
/// # Logout Functionality
/// The "Logout" button sends a GET request to `/api/logout`, clears the user's session,
/// and then redirects the user to the login page (`crate::Route::Login`).
/// Properties for the [`Sidebar`] component.
///
/// These properties enable managing and controlling the responsive mobile sidebar
/// layout and its visibility settings.
#[derive(Properties, PartialEq)]
pub struct SidebarComponentProps {
/// A boolean flag indicating whether the mobile sidebar is currently slid open (`true`) or hidden (`false`).
#[prop_or_default]
pub is_open: bool,
/// A callback emitted when the user requests to close/hide the mobile sidebar.
#[prop_or_default]
pub on_close: Callback<()>,
}
#[component(Sidebar)]
pub fn sidebar() -> Html {
pub fn sidebar(props: &SidebarComponentProps) -> Html {
let is_admin = use_state(|| None::<bool>);
let navigator = use_navigator().expect("Sidebar must be used within a Router");
@@ -379,49 +398,81 @@ pub fn sidebar() -> Html {
};
match *is_admin {
None => html! { <div class="sidebar-loading">{ "Loading..." }</div> },
None => html! { <div class="sidebar-loading">{ "Lade..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => html! {
<SidebarStateProvider>
<nav class="sidebar user">
<ul>
<Link<crate::Route> to={crate::Route::Home}>{ "󰟒" }</Link<crate::Route>>
<TicketMenu/>
<li class="logout-item">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Logout" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
Some(false) => {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider>
<nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}>
<ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/>
<li class="logout-item">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
},
// Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => html! {
<SidebarStateProvider>
<nav class="sidebar admin">
<ul>
<Link<crate::Route> to={crate::Route::Home}>{ "󰟒" }</Link<crate::Route>>
<TicketMenu/>
<UsersMenu/>
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
<li class="logout-item">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Logout" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
Some(true) => {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider>
<nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}>
<ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/>
<UsersMenu/>
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
<li class="logout-item">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
},
}
}
+69 -75
View File
@@ -180,7 +180,7 @@ pub fn submit_ticket_component() -> Html {
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
if room.is_none() {
status.set(Some("Invalid room".into()));
status.set(Some("Ungültiger Raum".into()));
return;
}
let category = (*category).clone();
@@ -188,7 +188,7 @@ pub fn submit_ticket_component() -> Html {
let description = (*description).clone();
let room = room.unwrap();
if !valid_rooms.contains(&room) {
status.set(Some("Room not allowed".into()));
status.set(Some("Raum nicht erlaubt".into()));
return;
}
let status = status.clone();
@@ -208,9 +208,11 @@ pub fn submit_ticket_component() -> Html {
.expect("Failed to build request");
match request.send().await {
Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
Err(err) => status.set(Some(format!("Network error: {}", err))),
Ok(response) if response.status() == 200 => {
status.set(Some("Erfolgreich".into()))
}
Ok(response) => status.set(Some(format!("Fehler: {}", response.status()))),
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
})
@@ -265,10 +267,7 @@ pub fn submit_ticket_component() -> Html {
Err(_) => None,
}
} else {
match raw_trim.parse::<i16>() {
Ok(n) => Some(n),
Err(_) => None,
}
raw_trim.parse::<i16>().ok()
}
};
@@ -289,7 +288,7 @@ pub fn submit_ticket_component() -> Html {
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "Create Ticket" }</h1>
<h1>{ "Ticket erstellen" }</h1>
</div>
<form {onsubmit}>
<label>{ "Betreff:" }
@@ -303,7 +302,7 @@ pub fn submit_ticket_component() -> Html {
<option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option>
<option value="Internet">{ "Internet" }</option>
<option value="iPad Koffer">{ "iPad Koffer" }</option>
<option value="Apple TV">{ "Apple TV" }</option>
<option value="Apple TV" selected=true>{ "Apple TV" }</option>
<option value="Docu Cam">{ "Dokumenten Kamera" }</option>
<option value="Sonstiges">{ "Sonstiges" }</option>
</select>
@@ -320,9 +319,9 @@ pub fn submit_ticket_component() -> Html {
html! {}
}
}
<button type="submit">{ "Send" }</button>
<button type="submit">{ "Absenden" }</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "View All Tickets" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Alle Tickets anzeigen" }</Link<crate::Route>>
{
if let Some(s) = &*status {
@@ -397,7 +396,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
if status == 200 {
match response.json::<Ticket>().await {
Ok(t) => ticket.set(Some(t)),
Err(err) => error.set(Some(format!("Parse error: {}", err))),
Err(err) => error.set(Some(format!("Parser-Fehler: {}", err))),
}
} else {
match response.json::<ApiError>().await {
@@ -406,13 +405,13 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("Server error: {}", status)));
error.set(Some(format!("Server-Fehler: {}", status)));
}
}
}
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -421,7 +420,6 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
}
let onsubmit = {
let status = status.clone();
let id = id.clone();
let error = error.clone();
Callback::from(move |e: SubmitEvent| {
@@ -437,7 +435,6 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
.unwrap_or_else(|| (*status).clone());
status.set(new_status.clone());
let id = id.clone();
let error = error.clone();
spawn_local(async move {
@@ -449,9 +446,11 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
.expect("Failed to construct request");
match request.send().await {
Ok(response) if response.status() == 200 => error.set(Some("Success".into())),
Ok(response) => error.set(Some(format!("Error: {}", response.status()))),
Err(err) => error.set(Some(format!("Network error: {}", err))),
Ok(response) if response.status() == 200 => {
error.set(Some("Erfolgreich".into()))
}
Ok(response) => error.set(Some(format!("Fehler: {}", response.status()))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
})
@@ -464,7 +463,6 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let ticket_state = ticket.clone();
let id = id;
Callback::from(move |e: MouseEvent| {
e.prevent_default();
@@ -493,10 +491,10 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
ticket_state.set(None); // clears the shown item
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
let txt = resp.text().await.unwrap_or_else(|_| "unbekannt".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
deleting.set(false);
});
@@ -504,9 +502,9 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
};
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> }
} else if let Some(t) = &*ticket {
html! {
<div>
@@ -539,18 +537,18 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
<button type="submit">{ "Aktualisieren" }</button>
</form>
<button onclick={ondelete} disabled={*deleting}>
<button onclick={ondelete} disabled={*deleting} class="delete">
{if *deleting {"Löschen..."} else {"Löschen"}}
</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Zurück zur Ticketübersicht" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllTickets} classes="return-to">{ "Zurück zur Ticketübersicht" }</Link<crate::Route>>
if let Some(err) = &*delete_error {
<p style="color:red">{ err.clone() }</p>
<p class="alert error">{ err.clone() }</p>
}
</div>
}
} else {
html! { <p>{ "No ticket found." }</p> }
html! { <p>{ "Kein Ticket gefunden." }</p> }
}
}
@@ -589,7 +587,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
/// ```
#[component(AllTickets)]
pub fn all_tickets_component() -> Html {
let tickets = use_state(|| Vec::<Ticket>::new());
let tickets = use_state(Vec::<Ticket>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let user = use_state(|| ActiveUser {
@@ -605,22 +603,22 @@ pub fn all_tickets_component() -> Html {
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/tickets");
let url = "/api/tickets".to_string();
match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("parse error: {}", e))),
Err(e) => error.set(Some(format!("Parser-Fehler: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
error.set(Some(format!("Status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -637,22 +635,20 @@ pub fn all_tickets_component() -> Html {
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
&& response.status() == 200
&& let Ok(json) = response.json::<serde_json::Value>().await
{
if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
let id = json
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_i64())
.and_then(|n| i16::try_from(n).ok());
let is_admin = json
.get("data")
.and_then(|d| d.get("is_admin"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
let id = json
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_i64())
.and_then(|n| i16::try_from(n).ok());
let is_admin = json
.get("data")
.and_then(|d| d.get("is_admin"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
});
|| ()
@@ -660,14 +656,14 @@ pub fn all_tickets_component() -> Html {
}
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> }
} else {
html! {
<div>
<div class="page-header">
<h1>{ "All Tickets" }</h1>
<h1>{ "Alle Tickets" }</h1>
</div>
<ul class="ticket-list">
{ for tickets.iter().filter(|t| t.status != "Archived" && (if user.is_admin { true } else if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| {
@@ -729,7 +725,7 @@ pub fn all_tickets_component() -> Html {
/// ```
#[component(ArchivedTickets)]
pub fn archived_tickets_component() -> Html {
let tickets = use_state(|| Vec::<Ticket>::new());
let tickets = use_state(Vec::<Ticket>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let user = use_state(|| ActiveUser {
@@ -745,22 +741,22 @@ pub fn archived_tickets_component() -> Html {
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/tickets");
let url = "/api/tickets".to_string();
match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("parse error: {}", e))),
Err(e) => error.set(Some(format!("Parser-Fehler: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
error.set(Some(format!("Status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -777,22 +773,20 @@ pub fn archived_tickets_component() -> Html {
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
&& response.status() == 200
&& let Ok(json) = response.json::<serde_json::Value>().await
{
if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
let id = json
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_i64())
.and_then(|n| i16::try_from(n).ok());
let is_admin = json
.get("data")
.and_then(|d| d.get("is_admin"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
let id = json
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_i64())
.and_then(|n| i16::try_from(n).ok());
let is_admin = json
.get("data")
.and_then(|d| d.get("is_admin"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
});
|| ()
@@ -800,9 +794,9 @@ pub fn archived_tickets_component() -> Html {
}
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> }
} else {
html! {
<div>
+51 -47
View File
@@ -1,3 +1,4 @@
use crate::dequote;
use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
use wasm_bindgen_futures::spawn_local;
@@ -181,9 +182,11 @@ pub fn register_component() -> Html {
.expect("Error building request");
match request.send().await {
Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
Err(err) => status.set(Some(format!("Network error: {}", err))),
Ok(response) if response.status() == 200 => {
status.set(Some("Erfolgreich".into()))
}
Ok(response) => status.set(Some(format!("Fehler: {}", response.status()))),
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
})
@@ -232,7 +235,7 @@ pub fn register_component() -> Html {
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "Register User" }</h1>
<h1>{ "Benutzer registrieren" }</h1>
</div>
<form {onsubmit}>
<label>{ "Vorname:" }
@@ -247,7 +250,7 @@ pub fn register_component() -> Html {
<label>{ "Admin:" }
<input type="checkbox" checked={*is_admin} onchange={admin_change}/>
</label>
<label>{ "Password:" }
<label>{ "Passwort:" }
<input type="password" value={(*pwd).clone()} oninput={pwd_change}/>
</label>
<button type="submit">{ "Bestätigen" }</button>
@@ -287,7 +290,7 @@ pub fn login_component() -> Html {
let username = use_state(|| "".to_string());
let pwd = use_state(|| "".to_string());
let loading = use_state(|| false);
let error = use_state(|| String::new());
let error = use_state(String::new);
let success = use_state(|| false);
let navigator = use_navigator().unwrap();
@@ -331,24 +334,25 @@ pub fn login_component() -> Html {
navigator.push(&crate::Route::Home);
}
Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text));
let text: serde_json::Value =
r.json().await.unwrap_or_else(|_| "unbekannt".into());
error.set(dequote!(format!("{}", text["message"].to_string())));
}
Err(err) => error.set(format!("Network error: {}", err)),
Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
}
});
})
};
html! {
<main class="content">
<main class="content login">
<div class="form-container">
<div class="page-header">
<h1>{ "Login" }</h1>
<h1>{ "Anmelden" }</h1>
</div>
<form {onsubmit}>
<input
placeholder="username"
placeholder="Benutzername"
value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -357,14 +361,14 @@ pub fn login_component() -> Html {
/>
<input
type="password"
placeholder="password"
placeholder="Passwort"
value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd.set(input.value());
})}
/>
<button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
<button type="submit" disabled={*loading}>{ if *loading { "Wird angemeldet..." } else { "Anmelden" } }</button>
if !error.is_empty() { <p class="alert error">{(*error).clone()}</p> }
</form>
</div>
@@ -401,7 +405,7 @@ pub fn login_component() -> Html {
/// ```
#[component(AllUsers)]
pub fn all_users_component() -> Html {
let users = use_state(|| Vec::<FilteredUser>::new());
let users = use_state(Vec::<FilteredUser>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
@@ -413,22 +417,22 @@ pub fn all_users_component() -> Html {
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/users");
let url = "/api/users".to_string();
match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<FilteredUser>>().await {
Ok(u) => users.set(u),
Err(err) => error.set(Some(format!("Parse error: {}", err))),
Err(err) => error.set(Some(format!("Parser-Fehler: {}", err))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
error.set(Some(format!("Status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -440,25 +444,25 @@ pub fn all_users_component() -> Html {
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
<h1>{ "Alle Benutzer" }</h1>
</div>
<p>{ "Loading..." }</p>
<p>{ "Lade..." }</p>
</div>
}
} else if let Some(e) = &*error {
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
<h1>{ "Alle Benutzer" }</h1>
</div>
<p class="alert error">{ format!("Error: {}", e) }</p>
<p class="alert error">{ format!("Fehler: {}", e) }</p>
</div>
}
} else {
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
<h1>{ "Alle Benutzer" }</h1>
</div>
<ul class="user-list">
{ for users.iter().map(|t| html! {
@@ -535,7 +539,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
if status == 200 {
match response.json::<FilteredUser>().await {
Ok(u) => user.set(Some(u)),
Err(err) => error.set(Some(format!("Parse error: {}", err))),
Err(err) => error.set(Some(format!("Parser-Fehler: {}", err))),
}
} else {
match response.json::<ApiError>().await {
@@ -544,13 +548,13 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("Server error: {}", status)));
error.set(Some(format!("Server-Fehler: {}", status)));
}
}
}
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -562,7 +566,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let last_name = use_state(|| "".to_string());
let username = use_state(|| "".to_string());
let make_admin = use_state(|| false);
let new_pwd = use_state(|| String::new());
let new_pwd = use_state(String::new);
let saving = use_state(|| false);
let save_error = use_state(|| None::<String>);
let save_success = use_state(|| false);
@@ -612,11 +616,10 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let save_error = save_error.clone();
let save_success = save_success.clone();
let user_state = user_state.clone();
let id = id;
spawn_local(async move {
let payload = UserUpdateScheme {
id: id,
id,
first_name,
last_name,
username,
@@ -639,10 +642,10 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
save_success.set(true);
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
let txt = resp.text().await.unwrap_or_else(|_| "unbekannt".into());
save_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => save_error.set(Some(format!("Network error: {}", err))),
Err(err) => save_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
saving.set(false);
});
@@ -656,15 +659,16 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let user_state = user.clone(); // or ticket
let id = id;
Callback::from(move |e: MouseEvent| {
e.prevent_default();
// confirm
if !web_sys::window()
.and_then(|w| {
w.confirm_with_message("Are you sure you want to delete this item?")
.ok()
w.confirm_with_message(
"Sind Sie sicher, dass Sie dieses Element löschen möchten?",
)
.ok()
})
.unwrap_or(false)
{
@@ -688,10 +692,10 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
user_state.set(None); // clears the shown item
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
let txt = resp.text().await.unwrap_or_else(|_| "unbekannt".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
deleting.set(false);
});
@@ -699,9 +703,9 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
};
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> }
} else if let Some(u) = &*user {
html! {
<div>
@@ -712,7 +716,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
<p><strong>{ "Ist Admin: " }</strong>{ u.is_admin }</p>
</div>
<h1>{ format!("User #{}", u.id) }</h1>
<h1>{ format!("Benutzer #{}", u.id) }</h1>
<form onsubmit={onsubmit}>
<div>
<label>{ "Vorname" }
@@ -756,7 +760,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
</label>
</div>
<div>
<label>{ "Neues Passwort (leer = unchanged)" }
<label>{ "Neues Passwort (leer = unverändert)" }
<input name="new_pwd" type="password"
value={(*new_pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
@@ -769,24 +773,24 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
<button type="submit" disabled={*saving}>{ if *saving { "Speichern..." } else { "Speichern" } }</button>
if *save_success {
<p style="color:green">{ "Updated successfully" }</p>
<p class="alert success">{ "Erfolgreich aktualisiert" }</p>
}
if let Some(err) = &*save_error {
<p style="color:red">{ err.clone() }</p>
<p class="alert error">{ err.clone() }</p>
}
</form>
<button onclick={ondelete} disabled={*deleting}>
<button onclick={ondelete} disabled={*deleting} class="delete">
{if *deleting {"Löschen..."} else {"Löschen"}}
</button>
<Link<crate::Route> to={crate::Route::AllUsers}>{ "Zurück zur Benutzerübersicht" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllUsers} classes="return-to">{ "Zurück zur Benutzerübersicht" }</Link<crate::Route>>
if let Some(err) = &*delete_error {
<p style="color:red">{ err.clone() }</p>
<p class="alert error">{ err.clone() }</p>
}
</div>
}
} else {
html! { <p>{ "No ticket found." }</p> }
html! { <p>{ "Kein Benutzer gefunden." }</p> }
}
}
+25 -30
View File
@@ -156,7 +156,7 @@ fn day_counts(partials: &[TicketPartial]) -> [usize; 7] {
chrono::Weekday::Sun => 6,
};
occ[idx] += 1;
current = current + chrono::Duration::days(1);
current += chrono::Duration::days(1);
}
occ
@@ -236,7 +236,7 @@ pub fn diagnostics_component() -> Html {
/// ```
#[component(TicketCount)]
pub fn ticket_count_component() -> Html {
let tickets = use_state(|| Vec::<Ticket>::new());
let tickets = use_state(Vec::<Ticket>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let user = use_state(|| ActiveUser {
@@ -252,7 +252,7 @@ pub fn ticket_count_component() -> Html {
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/tickets");
let url = "/api/tickets".to_string();
match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await {
@@ -284,22 +284,20 @@ pub fn ticket_count_component() -> Html {
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
&& response.status() == 200
&& let Ok(json) = response.json::<serde_json::Value>().await
{
if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
let id = json
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_i64())
.and_then(|n| i16::try_from(n).ok());
let is_admin = json
.get("data")
.and_then(|d| d.get("is_admin"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
let id = json
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_i64())
.and_then(|n| i16::try_from(n).ok());
let is_admin = json
.get("data")
.and_then(|d| d.get("is_admin"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
});
|| ()
@@ -307,17 +305,14 @@ pub fn ticket_count_component() -> Html {
}
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Lade..." }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else {
let status_conditions = |t: &Ticket| t.status == "ToDo" || t.status == "InProgress";
let count = tickets
.iter()
.filter(|t| {
status_conditions(t)
&& (user.is_admin || user.id.map_or(false, |uid| t.user_id == uid))
})
.filter(|t| status_conditions(t) && (user.is_admin || (user.id == Some(t.user_id))))
.count();
html! {
<div class="open-tickets">
@@ -357,8 +352,8 @@ pub fn ticket_count_component() -> Html {
/// ```
#[component(SubmitStats)]
pub fn submit_stats_component() -> Html {
let tickets = use_state(|| Vec::<TicketPartial>::new());
let users = use_state(|| Vec::<UserPartial>::new());
let tickets = use_state(Vec::<TicketPartial>::new);
let users = use_state(Vec::<UserPartial>::new);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
@@ -424,7 +419,7 @@ pub fn submit_stats_component() -> Html {
}
}
let weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
let (max_idx, _max_val) = counts
.iter()
.enumerate()
@@ -435,12 +430,12 @@ pub fn submit_stats_component() -> Html {
html! {
<div class="diagnostics-section">
if *loading {
<p>{ "Loading..." }</p>
<p>{ "Lade..." }</p>
}
if let Some(e) = &*error {
<p style="color: red;">{ e.clone() }</p>
<p class="alert error">{ e.clone() }</p>
}
<h3>{ "Tickets per weekday" }</h3>
<h3>{ "Tickets pro Wochentag" }</h3>
<div class="weekday-chart">
<div class="weekday-bars">
{ for (0..7).map(|i| {
+6 -6
View File
@@ -1,15 +1,15 @@
// Color Palette (Reference Style)
$color-bg: #f0f2f5;
$color-bg-dark: #121212;
$color-container: #ffffff;
$color-container-dark: #333333;
$color-bg: var(--color-bg);
$color-bg-dark: var(--color-bg-dark);
$color-container: var(--color-container);
$color-container-dark: var(--color-container-dark);
$color-sidebar: #0f172a;
$color-primary: #2b79c2;
$color-primary-hover: #1d5fa0;
$color-accent: #2b79c2;
$color-muted: #6b7280;
$color-text: #111827;
$color-text-dark: #e2e2e2;
$color-text: var(--color-text);
$color-text-dark: var(--color-text-dark);
// Status Colors
$color-status-todo: #ffcccc;
@@ -0,0 +1,75 @@
:root {
--color-bg: #f0f2f5;
--color-bg-dark: #121212;
--color-container: #ffffff;
--color-container-dark: #333333;
--color-text: #111827;
--color-text-dark: #e2e2e2;
}
// Automatically respect standard media query if no override is set
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #121212;
--color-container: #333333;
--color-text: #e2e2e2;
}
}
// Force Light Mode overrides
body.theme-light {
--color-bg: #f0f2f5;
--color-bg-dark: #f0f2f5;
--color-container: #ffffff;
--color-container-dark: #ffffff;
--color-text: #111827;
--color-text-dark: #111827;
}
// Force Dark Mode overrides
body.theme-dark {
--color-bg: #121212;
--color-bg-dark: #121212;
--color-container: #333333;
--color-container-dark: #333333;
--color-text: #e2e2e2;
--color-text-dark: #e2e2e2;
}
// Fixed positioning ensures NO other component's layout is ever altered
.dark-mode-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
width: 55px;
height: 55px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background-color: var(--color-container);
color: var(--color-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
svg {
width: 36px;
height: 36px;
stroke: var(--color-text);
transition: stroke 0.2s ease-in-out;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
background-color: var(--color-bg);
}
&:active {
transform: translateY(0);
}
}
@@ -37,3 +37,7 @@
transform: scale(1.05);
}
}
.content.login {
margin: 0;
}
@@ -270,3 +270,25 @@ input[type="checkbox"] {
}
}
}
.return-to {
display: block;
padding: 16px 2rem;
background: #2b79c2;
background-color: rgb(43, 121, 194);
color: white;
text-decoration: none;
text-align: center;
border-radius: 0.5rem;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: #1d5fa0;
}
}
.delete {
display: block;
width: 100%;
text-align: center;
}
@@ -49,6 +49,126 @@
text-align: left;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: $spacing-md;
.home {
flex-grow: 1;
display: flex;
justify-content: center;
@media (max-width: 768px) {
padding-left: 36px;
}
}
.sidebar-close {
display: none;
background: transparent;
border: none;
color: #fff;
cursor: pointer;
padding: 8px;
border-radius: 50%;
align-items: center;
justify-content: center;
transition: background 0.2s ease-in-out;
margin-bottom: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
svg {
width: 20px;
height: 20px;
stroke: #fff;
}
@media (max-width: 768px) {
display: flex;
}
}
}
.home-svg {
width: 24px;
height: 24px;
vertical-align: middle;
margin-right: 8px;
}
&.user { width: 220px; background: darken($color-sidebar, 6%); }
}
// Floating mobile menu toggle button
.mobile-menu-toggle {
display: none;
@media (max-width: 768px) {
display: flex;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 998;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background-color: var(--color-container);
color: var(--color-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease-in-out;
svg {
width: 24px;
height: 24px;
stroke: var(--color-text);
transition: stroke 0.2s ease-in-out;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
background-color: var(--color-bg);
}
&:active {
transform: translateY(0);
}
}
}
// Overlay backdrop that dims the screen and allows dismissals on mobile
.sidebar-overlay {
display: none;
@media (max-width: 768px) {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
&.open {
opacity: 1;
pointer-events: auto;
}
}
}
+1
View File
@@ -10,6 +10,7 @@
@use "components/diagnostics";
@use "components/pages";
@use "components/setup";
@use "components/dark_mode";
* {
box-sizing: border-box;
+1 -1
View File
@@ -1 +1 @@
#![doc = include_str!("../README.md")]
#![doc = include_str!("../README.cargo.md")]