Compare commits
4 Commits
4db5519341
...
4cd7f94ef2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cd7f94ef2 | ||
|
|
cbc08fe071 | ||
|
|
633022f853 | ||
|
|
33cbfeadc5 |
126
AI.md
Normal file
126
AI.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# AI Guidelines — Domotique Rust/Leptos
|
||||
|
||||
## Project overview
|
||||
|
||||
Home automation dashboard built with Leptos (full-stack Rust). Exposes a web UI for managing quick links and controlling shutters via HTTP API to IoT devices. Runs as a systemd service with SSR + WASM hydration.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Framework | Leptos 0.6 (SSR + hydration) |
|
||||
| Server | Axum 0.7 + Tokio |
|
||||
| Database | MySQL via SQLx 0.8 (compile-time checked queries) |
|
||||
| Frontend | WASM (wasm-bindgen), Tailwind CSS 4 |
|
||||
| Build | cargo-leptos, Makefile |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Dual-target compilation
|
||||
|
||||
- `ssr` feature: binary target, runs Axum, has DB access, pre-renders HTML
|
||||
- `hydrate` feature: lib target compiled to WASM, mounts into DOM
|
||||
|
||||
Gate all server-only code (database, filesystem, secrets) with `#[cfg(feature = "ssr")]`.
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
src/
|
||||
app.rs # App component, router, nav
|
||||
lib.rs # WASM entry point (hydrate feature)
|
||||
main.rs # Binary entry point (ssr feature)
|
||||
setup.rs # Axum server initialization
|
||||
database.rs # Global MySqlPool (OnceLock)
|
||||
models/ # Data models + SQL queries
|
||||
routes/ # Page components + server functions
|
||||
migrations/ # SQLx migrations (timestamp-named)
|
||||
style/ # Compiled Tailwind output
|
||||
input.css # Tailwind source
|
||||
```
|
||||
|
||||
### Data flow
|
||||
|
||||
1. Page component defines a `Resource` that calls a server function
|
||||
2. Server function (`#[server]`) queries the DB via `sqlx::query!`
|
||||
3. Mutations use `create_server_action` or `create_action`
|
||||
4. After mutation, increment a version signal to trigger resource refetch
|
||||
|
||||
## Coding conventions
|
||||
|
||||
### Language & naming
|
||||
|
||||
- UI labels and user-facing strings are in **French** (e.g., `Liens`, `Volets`)
|
||||
- Rust identifiers follow standard conventions: `snake_case` for functions/variables, `PascalCase` for types and components
|
||||
- Server function names are descriptive PascalCase (e.g., `GetLinksAction`, `LinkAction`)
|
||||
|
||||
### Components
|
||||
|
||||
- One `#[component]` per logical unit, small and single-responsibility
|
||||
- Use `create_rw_signal` / `create_signal` for local state
|
||||
- Use `Resource` for async data; include a version signal for cache invalidation
|
||||
- Modal pattern for forms: single component toggled between create/edit mode
|
||||
|
||||
### Server functions
|
||||
|
||||
```rust
|
||||
#[server(MyAction, "/api", "GetJson")] // or "Cbor" for mutations
|
||||
pub async fn my_action(...) -> Result<T, ServerFnError> {
|
||||
// DB access gated by ssr feature implicitly via #[server]
|
||||
}
|
||||
```
|
||||
|
||||
- Return `Result<T, ServerFnError>`
|
||||
- Log errors with `tracing::error!` before returning a generic error to the client
|
||||
- Never expose internal error details to the client
|
||||
|
||||
### Database
|
||||
|
||||
- Use `sqlx::query!` / `sqlx::query_as!` for compile-time checked SQL
|
||||
- Access the pool via the global `get_db()` helper in `database.rs`
|
||||
- Keep queries in the model file for the relevant type (`models/link.rs`)
|
||||
- Connection pool max: 4 connections — avoid holding connections across await points
|
||||
|
||||
### Styling
|
||||
|
||||
- Tailwind utility classes only — no custom CSS except CSS variables in `input.css`
|
||||
- Dark-first design; use `dark:` variants when needed
|
||||
- Custom color tokens (defined in `input.css`, OKLCH color space):
|
||||
- `prim`, `prim-light` — primary accent
|
||||
- `second`, `second-dark` — secondary
|
||||
- `third`, `third-light` — tertiary accent
|
||||
- `fourth`, `green` — utility
|
||||
- Responsive breakpoints: `xs:`, `sm:`, `md:`, `lg:`
|
||||
|
||||
### Error handling
|
||||
|
||||
- Server functions: `Result<T, ServerFnError>`, log then return generic message
|
||||
- Components: handle `None` / error states explicitly in the view
|
||||
- Avoid `unwrap()` in production paths; use `?` or explicit error handling
|
||||
|
||||
## Development workflow
|
||||
|
||||
```bash
|
||||
# Start dev server (auto-reload)
|
||||
cargo leptos watch
|
||||
|
||||
# Watch Tailwind in parallel
|
||||
npx tailwindcss -i input.css -o ./style/output.css --watch
|
||||
|
||||
# Run DB migrations
|
||||
sqlx migrate run
|
||||
|
||||
# Production deploy
|
||||
make install # builds Tailwind + WASM + binary, restarts systemd service
|
||||
```
|
||||
|
||||
Environment variables are loaded from `.env` (`DATABASE_URL` required).
|
||||
|
||||
## Key patterns to follow
|
||||
|
||||
- **No new dependencies** without a clear reason — the stack is intentionally minimal
|
||||
- **No JavaScript** — all interactivity through Leptos/WASM
|
||||
- **Keep components in `routes/`** unless genuinely reusable across multiple pages
|
||||
- **New DB tables** require a migration file in `migrations/`
|
||||
- **External API calls** (e.g., shutters) go through server functions, never from WASM directly
|
||||
- **Do not add features** beyond what is asked; this is a personal tool, not a framework
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE links DROP COLUMN alternate_link;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE links ADD COLUMN alternate_link TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE links DROP COLUMN dashboard;
|
||||
1
migrations/20260412150652_add_dashboard_to_links.up.sql
Normal file
1
migrations/20260412150652_add_dashboard_to_links.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE links ADD COLUMN dashboard BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -59,13 +59,6 @@ pub fn Navigation() -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> impl IntoView {
|
||||
view! {
|
||||
<h1 class="m-2">Home</h1>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn NotFound() -> impl IntoView {
|
||||
view! {
|
||||
|
||||
@@ -6,6 +6,8 @@ pub struct Link {
|
||||
pub link: String,
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub alternate_link: String,
|
||||
pub dashboard: bool,
|
||||
position: i64,
|
||||
created_at: String,
|
||||
}
|
||||
@@ -16,12 +18,16 @@ impl Link {
|
||||
name: String,
|
||||
link: String,
|
||||
icon: String,
|
||||
alternate_link: String,
|
||||
dashboard: bool,
|
||||
) -> Result<sqlx::mysql::MySqlQueryResult, sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO links (name, link, icon, position, created_at) VALUES (?, ?, ?, (SELECT COALESCE(MAX(position) + 1, 1) FROM links lin), ?)",
|
||||
"INSERT INTO links (name, link, icon, alternate_link, dashboard, position, created_at) VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position) + 1, 1) FROM links lin), ?)",
|
||||
name,
|
||||
link,
|
||||
icon,
|
||||
alternate_link,
|
||||
dashboard,
|
||||
chrono::Local::now().naive_local(),
|
||||
)
|
||||
.execute(crate::database::get_db())
|
||||
@@ -30,12 +36,16 @@ impl Link {
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_all() -> Result<Vec<Self>, sqlx::Error> {
|
||||
sqlx::query!("SELECT id, name, link, icon, position, created_at FROM links ORDER BY position")
|
||||
sqlx::query!(
|
||||
"SELECT id, name, link, icon, alternate_link, dashboard, position, created_at FROM links ORDER BY position"
|
||||
)
|
||||
.map(|x| Self {
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
link: x.link,
|
||||
icon: x.icon,
|
||||
alternate_link: x.alternate_link,
|
||||
dashboard: x.dashboard != 0,
|
||||
position: x.position,
|
||||
created_at: x.created_at.format("%d/%m/%Y %H:%M").to_string(),
|
||||
})
|
||||
@@ -49,7 +59,7 @@ impl Link {
|
||||
direction: String,
|
||||
) -> Result<sqlx::mysql::MySqlQueryResult, sqlx::Error> {
|
||||
let link = sqlx::query!(
|
||||
"SELECT id, name, link, icon, position, created_at FROM links WHERE id = ?",
|
||||
"SELECT id, name, link, icon, alternate_link, dashboard, position, created_at FROM links WHERE id = ?",
|
||||
link_id
|
||||
)
|
||||
.map(|x| Self {
|
||||
@@ -57,6 +67,8 @@ impl Link {
|
||||
name: x.name,
|
||||
link: x.link,
|
||||
icon: x.icon,
|
||||
alternate_link: x.alternate_link,
|
||||
dashboard: x.dashboard != 0,
|
||||
position: x.position,
|
||||
created_at: x.created_at.format("%d/%m/%Y %H:%M").to_string(),
|
||||
})
|
||||
@@ -100,12 +112,16 @@ impl Link {
|
||||
name: String,
|
||||
link: String,
|
||||
icon: String,
|
||||
alternate_link: String,
|
||||
dashboard: bool,
|
||||
) -> Result<sqlx::mysql::MySqlQueryResult, sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"UPDATE links SET name = ?, link = ?, icon = ? WHERE id = ?",
|
||||
"UPDATE links SET name = ?, link = ?, icon = ?, alternate_link = ?, dashboard = ? WHERE id = ?",
|
||||
name,
|
||||
link,
|
||||
icon,
|
||||
alternate_link,
|
||||
dashboard,
|
||||
id
|
||||
)
|
||||
.execute(crate::database::get_db())
|
||||
|
||||
57
src/routes/home.rs
Normal file
57
src/routes/home.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
|
||||
#[server(GetDashboardLinksAction, "/api", "GetJson")]
|
||||
#[tracing::instrument]
|
||||
pub async fn get_dashboard_links() -> Result<Vec<crate::models::Link>, ServerFnError> {
|
||||
crate::models::Link::get_all()
|
||||
.await
|
||||
.map(|links| links.into_iter().filter(|l| l.dashboard).collect())
|
||||
.map_err(|x| {
|
||||
let err = format!("Error while fetching dashboard links: {x:?}");
|
||||
tracing::error!("{err}");
|
||||
ServerFnError::ServerError("Could not fetch dashboard links, try again later".into())
|
||||
})
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> impl IntoView {
|
||||
let links = create_resource(|| (), |_| async move { get_dashboard_links().await });
|
||||
|
||||
view! {
|
||||
<Title text="Bienvenue à la maison"/>
|
||||
<ul class="flex gap-5 m-5 flex-wrap justify-center">
|
||||
<Suspense fallback=move || view! { <p>"Chargement..."</p> }>
|
||||
<ErrorBoundary fallback=|_| {
|
||||
view! { <p class="error-messages text-xs-center">"Something went wrong."</p> }
|
||||
}>
|
||||
{move || links.get().map(move |x| x.map(move |c| {
|
||||
view! {
|
||||
<For each=move || c.clone().into_iter().enumerate()
|
||||
key=|(i, _)| *i
|
||||
children=move |(_, link)| {
|
||||
view! { <DashboardLink link /> }
|
||||
}/>
|
||||
}
|
||||
}))}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn DashboardLink(link: crate::models::Link) -> impl IntoView {
|
||||
view! {
|
||||
<li class="w-44 lg:w-fit">
|
||||
<a class="bg-prim-light border-b border-third container flex flex-col lg:gap-2 lg:flex-row w-full item-center hover:scale-110 transition hover:bg-prim-lightest rounded-lg text-center hover:text-third px-5 py-4"
|
||||
target="_blank"
|
||||
href=link.link.clone()>
|
||||
<img src=link.icon.clone()
|
||||
alt=link.name.clone()
|
||||
class="size-32 lg:size-10 object-cover overflow-hidden" />
|
||||
<span class="flex-1 text-2xl mt-2 lg:mt-0 flex justify-center items-center">{link.name.clone()}</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ pub async fn get_links() -> Result<Vec<crate::models::Link>, ServerFnError> {
|
||||
}
|
||||
|
||||
#[server(LinkAction, "/api")]
|
||||
pub async fn add_value(name: String, link: String, icon: String) -> Result<(), ServerFnError> {
|
||||
crate::models::Link::insert(name, link, icon)
|
||||
pub async fn add_value(name: String, link: String, icon: String, alternate_link: String, dashboard: bool) -> Result<(), ServerFnError> {
|
||||
crate::models::Link::insert(name, link, icon, alternate_link, dashboard)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|x| {
|
||||
@@ -29,6 +29,7 @@ pub fn Links() -> impl IntoView {
|
||||
let (show_form, set_show_form) = create_signal(false);
|
||||
let (edit_link, set_edit_link) = create_signal::<Option<crate::models::Link>>(None);
|
||||
let (edit, set_edit) = create_signal(false);
|
||||
let (use_alternate, set_use_alternate) = create_signal(false);
|
||||
|
||||
let link_action = create_server_action::<LinkAction>();
|
||||
let update_action = create_server_action::<UpdateLinkAction>();
|
||||
@@ -59,7 +60,7 @@ pub fn Links() -> impl IntoView {
|
||||
children=move |(_, link)| {
|
||||
let link = create_rw_signal(link);
|
||||
view!{
|
||||
<Link link edit set_edit_link set_show_form links />
|
||||
<Link link edit use_alternate set_edit_link set_show_form links />
|
||||
}
|
||||
}/>
|
||||
}
|
||||
@@ -69,6 +70,13 @@ pub fn Links() -> impl IntoView {
|
||||
</ul>
|
||||
|
||||
<div class="flex justify-end gap-5 mx-5">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox"
|
||||
prop:checked=move || use_alternate.get()
|
||||
on:change=move |ev| set_use_alternate.set(event_target_checked(&ev))
|
||||
class="accent-third size-4 cursor-pointer" />
|
||||
"Lien alternatif"
|
||||
</label>
|
||||
<button on:click=move |_| {set_edit.set(!edit.get())} class="bg-prim-light hover:bg-prim-lightest rounded-full px-5 py-3 text-second-dark hover:text-third transition-colors">"Modifier"</button>
|
||||
<button on:click=move |_| {
|
||||
set_edit_link.set(None);
|
||||
@@ -105,15 +113,16 @@ pub async fn change_position(link_id: String, direction: String) -> Result<(), S
|
||||
fn Link<T: 'static + Clone, S: 'static>(
|
||||
link: RwSignal<crate::models::Link>,
|
||||
edit: ReadSignal<bool>,
|
||||
use_alternate: ReadSignal<bool>,
|
||||
set_edit_link: WriteSignal<Option<crate::models::Link>>,
|
||||
set_show_form: WriteSignal<bool>,
|
||||
links: Resource<T, S>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<li class="w-44 lg:w-fit">
|
||||
<a class="bg-prim-light container flex flex-col lg:gap-2 lg:flex-row w-full item-center hover:scale-110 transition hover:bg-prim-lightest border-b hover:border-third border-transparent rounded-lg text-center hover:text-third px-5 py-4"
|
||||
<a class=move || format!("bg-prim-light container flex flex-col lg:gap-2 lg:flex-row w-full item-center hover:scale-110 transition hover:bg-prim-lightest border-b hover:border-third rounded-lg text-center hover:text-third px-5 py-4 {}", if link.with(|x| x.dashboard) { "border-third" } else { "border-transparent" })
|
||||
target="_blank"
|
||||
href={move || link.with(|x| x.link.to_string())}>
|
||||
href={move || link.with(|x| if use_alternate.get() { x.alternate_link.clone() } else { x.link.clone() })}>
|
||||
<img src={move || link.with(|x| x.icon.to_string())}
|
||||
alt={move || link.with(|x| x.name.to_string())}
|
||||
class="size-32 lg:size-10 object-cover overflow-hidden" />
|
||||
@@ -194,10 +203,16 @@ fn DeleteButton<T: 'static + Clone, S: 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[server(UpdateLinkAction, "/api")]
|
||||
pub async fn update_link(id: String, name: String, link: String, icon: String) -> Result<(), ServerFnError> {
|
||||
crate::models::Link::update(id, name, link, icon)
|
||||
pub async fn update_link(
|
||||
id: String,
|
||||
name: String,
|
||||
link: String,
|
||||
icon: String,
|
||||
alternate_link: String,
|
||||
dashboard: bool,
|
||||
) -> Result<(), ServerFnError> {
|
||||
crate::models::Link::update(id, name, link, icon, alternate_link, dashboard)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|x| {
|
||||
@@ -216,8 +231,13 @@ fn LinkFormModal(
|
||||
) -> impl IntoView {
|
||||
let is_edit = link.is_some();
|
||||
let (name, set_name) = create_signal(link.as_ref().map(|x| x.name.clone()).unwrap_or_default());
|
||||
let (link_url, set_link_url) = create_signal(link.as_ref().map(|x| x.link.clone()).unwrap_or_default());
|
||||
let (link_url, set_link_url) =
|
||||
create_signal(link.as_ref().map(|x| x.link.clone()).unwrap_or_default());
|
||||
let (icon, set_icon) = create_signal(link.as_ref().map(|x| x.icon.clone()).unwrap_or_default());
|
||||
let (alternate_link, set_alternate_link) =
|
||||
create_signal(link.as_ref().map(|x| x.alternate_link.clone()).unwrap_or_default());
|
||||
let (dashboard, set_dashboard) =
|
||||
create_signal(link.as_ref().map(|x| x.dashboard).unwrap_or(false));
|
||||
let link_id = link.as_ref().map(|x| x.id.to_string());
|
||||
|
||||
view! {
|
||||
@@ -238,6 +258,10 @@ fn LinkFormModal(
|
||||
set_link_url=set_link_url
|
||||
icon=icon
|
||||
set_icon=set_icon
|
||||
alternate_link=alternate_link
|
||||
set_alternate_link=set_alternate_link
|
||||
dashboard=dashboard
|
||||
set_dashboard=set_dashboard
|
||||
/>
|
||||
<FormButtons set_show=set_show />
|
||||
</ActionForm>
|
||||
@@ -252,6 +276,10 @@ fn LinkFormModal(
|
||||
set_link_url=set_link_url
|
||||
icon=icon
|
||||
set_icon=set_icon
|
||||
alternate_link=alternate_link
|
||||
set_alternate_link=set_alternate_link
|
||||
dashboard=dashboard
|
||||
set_dashboard=set_dashboard
|
||||
/>
|
||||
<FormButtons set_show=set_show />
|
||||
</ActionForm>
|
||||
@@ -270,6 +298,10 @@ fn LinkFormFields(
|
||||
set_link_url: WriteSignal<String>,
|
||||
icon: ReadSignal<String>,
|
||||
set_icon: WriteSignal<String>,
|
||||
alternate_link: ReadSignal<String>,
|
||||
set_alternate_link: WriteSignal<String>,
|
||||
dashboard: ReadSignal<bool>,
|
||||
set_dashboard: WriteSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div>
|
||||
@@ -290,6 +322,16 @@ fn LinkFormFields(
|
||||
class="text-center bg-prim border border-transparent rounded-lg focus:border-third px-2 py-2 w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mt-3 mb-1">"Lien alternatif"</label>
|
||||
<input type="url"
|
||||
name="alternate_link"
|
||||
placeholder="http..."
|
||||
prop:value=move || alternate_link.get()
|
||||
on:input=move |ev| set_alternate_link.set(event_target_value(&ev))
|
||||
class="text-center bg-prim border border-transparent rounded-lg focus:border-third px-2 py-2 w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mt-3 mb-1">"Icône"</label>
|
||||
<input type="url"
|
||||
@@ -299,6 +341,15 @@ fn LinkFormFields(
|
||||
on:input=move |ev| set_icon.set(event_target_value(&ev))
|
||||
class="text-center bg-prim border border-transparent rounded-lg focus:border-third px-2 py-2 w-full" />
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="dashboard" prop:value=move || dashboard.get().to_string() />
|
||||
<label class="flex items-center gap-2 mt-4 cursor-pointer select-none">
|
||||
<input type="checkbox"
|
||||
prop:checked=move || dashboard.get()
|
||||
on:change=move |ev| set_dashboard.set(event_target_checked(&ev))
|
||||
class="accent-third size-4 cursor-pointer" />
|
||||
"Tableau de bord"
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod home;
|
||||
mod link;
|
||||
mod shutters;
|
||||
|
||||
pub use home::*;
|
||||
pub use link::*;
|
||||
pub use shutters::*;
|
||||
|
||||
@@ -20,6 +20,8 @@ pub fn Shutters() -> impl IntoView {
|
||||
<Shutter name="Salon fenêtre".to_string() shade_id=4 />
|
||||
<Shutter name="Salon vidéo".to_string() shade_id=5 />
|
||||
<Shutter name="Bureau".to_string() shade_id=1 />
|
||||
<Shutter name="Chambre Célestine".to_string() shade_id=3 />
|
||||
<Shutter name="Chambre Marius".to_string() shade_id=7 />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user