From 0d531d5b7cc0fd09d53251ac26171e6e61711f68 Mon Sep 17 00:00:00 2001 From: Bytemalte Date: Mon, 16 Mar 2026 15:02:36 +0100 Subject: [PATCH] docs: add ARCHITECTURE.md and AGENTS.md, split CSS, fix justfile - Add ARCHITECTURE.md with full system overview (data flow, modules, build) - Add AGENTS.md as a guide for future AI agents working on this project - Split monolithic styles.css into focused files under assets/: variables, animations, layout, navbar, header, cards, config, filters, utils, greet - Fix justfile: replace `cargo run tauri dev/build` with `cargo tauri dev/build` Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 186 +++++++++++ ARCHITECTURE.md | 174 +++++++++++ assets/animations.css | 37 +++ assets/cards.css | 50 +++ assets/config.css | 196 ++++++++++++ assets/filters.css | 102 ++++++ assets/greet.css | 36 +++ assets/header.css | 97 ++++++ assets/layout.css | 13 + assets/navbar.css | 48 +++ assets/utils.css | 65 ++++ assets/variables.css | 40 +++ justfile | 12 + styles.css | 698 +----------------------------------------- 14 files changed, 1065 insertions(+), 689 deletions(-) create mode 100644 AGENTS.md create mode 100644 ARCHITECTURE.md create mode 100644 assets/animations.css create mode 100644 assets/cards.css create mode 100644 assets/config.css create mode 100644 assets/filters.css create mode 100644 assets/header.css create mode 100644 assets/layout.css create mode 100644 assets/utils.css create mode 100644 assets/variables.css create mode 100644 justfile diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4005562 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,186 @@ +# Agent Guide: marstemedia + +This document is for AI agents working on this codebase. Read it before touching anything. + +--- + +## What is this project? + +**marstemedia** is a cross-platform desktop (+ Android) news reader app built with: +- **Tauri v2** as the native shell +- **Yew** (Rust → WebAssembly) for the UI +- **Rust** for all backend logic (networking, file I/O, cryptography) + +It has three features: +1. **Nostr feed** — anonymous browsing of decentralized social posts, filterable by hashtag +2. **RSS reader** — fetches and displays articles from a user-configurable list of RSS/Atom feeds +3. **AI news** — one-click tech news summary from Groq (Llama 3) or OpenRouter + +--- + +## Repository Layout + +``` +marstemedia/ +├── src/ # Frontend (Yew/Wasm) +│ ├── main.rs # Wasm bootstrap +│ ├── app.rs # Router + top-level layout +│ ├── navbar/ +│ │ ├── mod.rs +│ │ └── navbar.rs # Bottom navigation dock +│ └── pages/ +│ ├── mod.rs +│ ├── home.rs # Nostr feed page +│ ├── news.rs # RSS + AI news page +│ └── greet.rs # Placeholder/demo page +│ +├── src-tauri/ # Backend (native Rust) +│ ├── Cargo.toml +│ ├── tauri.conf.json # App config (window size, build commands) +│ ├── capabilities/ +│ │ └── default.json # Tauri capability permissions +│ └── src/ +│ ├── main.rs # Desktop executable entry +│ ├── lib.rs # Tauri setup, command registration +│ ├── home.rs # Nostr: fetch_nostr_posts command +│ └── news.rs # RSS + AI: all news commands + NewsState +│ └── easy-nostr/ # Local path-dependency (nostr-sdk wrapper) +│ +├── Cargo.toml # Frontend workspace root +├── styles.css # All CSS for the app +├── index.html # Trunk entry point +├── Trunk.toml # Trunk (Wasm bundler) config +├── justfile # Dev shortcuts +└── .gitea/workflows/ + └── android.yaml # CI: builds Android APK +``` + +--- + +## How to Add a New Page + +1. Create `src/pages/yourpage.rs` as a Yew `#[function_component]`. +2. Export it in `src/pages/mod.rs`: `pub mod yourpage;` +3. Add a variant to the `Route` enum in `src/app.rs`: + ```rust + #[at("/yourpage")] + YourPage, + ``` +4. Add a match arm in the `switch` function in `src/app.rs`: + ```rust + Route::YourPage => html! { }, + ``` +5. Add a `>` in `src/navbar/navbar.rs`. + +--- + +## How to Add a New Backend Command + +1. Write your async function in the appropriate module (`src-tauri/src/home.rs`, `news.rs`, or a new file). +2. Annotate it with `#[tauri::command]`. +3. If you need shared state, add a field to `NewsState` in `src-tauri/src/news.rs`, or create a new state struct and call `.manage()` in `lib.rs`. +4. Register the command in `src-tauri/src/lib.rs` inside `tauri::generate_handler![...]`. +5. In the frontend, call it with `invoke("your_command_name", args)`. + +--- + +## How to Call a Backend Command from the Frontend + +Every page that needs backend calls declares this extern at the top: + +```rust +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"], catch)] + async fn invoke(cmd: &str, args: JsValue) -> Result; +} +``` + +Serialize args with `serde_wasm_bindgen::to_value(...)`, deserialize results with `serde_wasm_bindgen::from_value(...)`. Use `spawn_local` to run async calls inside Yew callbacks. + +--- + +## Key Patterns to Know + +### State management (frontend) +Yew uses `use_state` hooks. There is no global store — state lives inside components. If two pages need to share data, consider lifting state to `app.rs` or using Tauri's managed state on the backend. + +### State management (backend) +`NewsState` in `src-tauri/src/news.rs` is registered with `.manage()` and injected into commands via `State<'_, NewsState>`. All fields are `Mutex` — always lock, read/write, and drop the lock quickly. **API keys are in-memory only** (not saved to disk). + +### Config persistence +RSS feeds are saved to `/rss.ron` using the `ron` crate. Access the config path via `app.path().app_config_dir()`. Pattern is: read on load, write on save, keep in-memory state in sync. + +### Markdown rendering +The `News` page renders article content as Markdown using `pulldown-cmark`. The output is injected with `Html::from_html_unchecked`. Only use this for content you control or have sanitized. + +### Privacy pattern (Nostr) +A new random keypair is generated on every `fetch_nostr_posts` call. This is intentional — do not change this to a persistent key without understanding the privacy implications. + +--- + +## CSS + +`styles.css` in the project root is the single entry point — it only contains `@import` statements. All actual styles are split into files under `assets/`: + +| File | What's inside | +|---|---| +| `assets/variables.css` | CSS custom properties (colors, fonts), reset, `body`/`html` | +| `assets/layout.css` | `.feed-container`, responsive media queries | +| `assets/navbar.css` | `.navbar-dock`, `.nav-link` | +| `assets/header.css` | `.feed-header`, `.mode-toggle`, `.toggle-btn`, `.action-btn`, `.provider-switch` | +| `assets/cards.css` | `.post-card`, `.markdown-body`, `.post-category-badge` | +| `assets/config.css` | `.config-panel`, RSS list, inputs, `.add-btn`, `.delete-btn`, `.save-master-btn` | +| `assets/filters.css` | Hashtag chips (Home), `.category-bar`, `.cat-btn` (News) | +| `assets/animations.css` | `@keyframes float`, `slideIn`, `pulse-subtle` | +| `assets/utils.css` | `.reload-btn-large`, `.error-box`, `.loading-spinner`, `.status-msg` | +| `assets/greet.css` | `.robot-container`, `.robot-icon`, `.bubble` | + +When adding styles for a new page, create a new file in `assets/` and add an `@import` line to `styles.css`. There is no CSS framework — vanilla CSS with custom classes. + +--- + +## Build & Dev + +```bash +# Start dev server (hot reload) +cargo tauri dev + +# Build for desktop release +cargo tauri build + +# Build for Android (requires Android SDK + NDK) +cargo tauri android build + +# Frontend only (no Tauri shell) +trunk serve +``` + +The `justfile` may contain additional shortcuts — check it first. + +--- + +## Dependencies Worth Knowing + +| Crate | Used for | +|---|---| +| `yew` | UI components | +| `yew-router` | Client-side routing | +| `wasm-bindgen` / `wasm-bindgen-futures` | Wasm ↔ JS bridge | +| `serde-wasm-bindgen` | Serialize/deserialize across the Tauri IPC | +| `pulldown-cmark` | Markdown → HTML in the frontend | +| `nostr-sdk` | Nostr protocol (via `easy-nostr` wrapper) | +| `reqwest` | HTTP client (rustls, no OpenSSL) | +| `feed-rs` | RSS/Atom feed parsing | +| `ron` | Config file serialization | +| `tauri-plugin-opener` | Open URLs in system browser | + +--- + +## What to Watch Out For + +- **`easy-nostr`** is a local path dependency at `src-tauri/easy-nostr/`. It is also a private Git repo cloned by CI. If you need to update it, clone it manually into that path. +- **No OpenSSL** — both `reqwest` usages (frontend and backend) use `rustls-tls` with `default-features = false`. Keep it that way to avoid cross-compilation pain on Android/CI. +- **`wasm32-unknown-unknown`** target is required for the frontend. `aarch64-linux-android` and friends are required for Android CI. +- The `Greet` page (`src/pages/greet.rs`) appears to be a leftover demo page from the Tauri template. It is still linked in the navbar — decide whether to keep or remove it. +- API keys entered by the user are held in `Mutex` fields in `NewsState`. They are lost when the app restarts. There is no persistence for keys by design. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fccc2bc --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,174 @@ +# Architecture: marstemedia + +marstemedia is a desktop (and Android) application built with **Tauri v2**. The UI is written in Rust compiled to WebAssembly using **Yew**, and the backend logic runs as native Rust code inside the Tauri host process. + +--- + +## Big Picture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Tauri Desktop App │ +│ │ +│ ┌──────────────────────┐ IPC (invoke) ┌──────────┐ │ +│ │ Frontend (Wasm) │ ──────────────► │ Backend │ │ +│ │ Yew / Rust │ ◄────────────── │ Rust │ │ +│ │ src/ │ JSON results │ src-tauri│ │ +│ └──────────────────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────┘ + ▲ renders inside a webview (WebKit/Edge) +``` + +The frontend never touches the network directly for heavy operations — it sends named commands via `window.__TAURI__.core.invoke()` to the backend, which does the actual networking, file I/O, and cryptography. + +--- + +## Frontend (`src/`) + +Built with **Yew 0.21** (React-style component model for Rust/Wasm) and **yew-router** for client-side routing. + +### Entry point + +| File | Role | +|---|---| +| `src/main.rs` | Bootstraps the Wasm app, mounts the `App` component | +| `src/app.rs` | Defines the `Route` enum and the top-level layout (router + navbar) | + +### Routes + +| Path | Component | File | +|---|---|---| +| `/` | `Home` | `src/pages/home.rs` | +| `/news` | `News` | `src/pages/news.rs` | +| `/greet` | `Greet` | `src/pages/greet.rs` | + +### Components + +- **`Navbar`** (`src/navbar/navbar.rs`) — Fixed bottom dock with links to all three routes. Reads the current route via `use_route` to highlight the active link. +- **`Home`** (`src/pages/home.rs`) — Nostr feed. Manages hashtag filter state, invokes `fetch_nostr_posts` on load and on user interaction, renders posts as `PostCard` components. +- **`News`** (`src/pages/news.rs`) — Dual-mode news page. Toggles between AI mode (Groq / OpenRouter) and RSS mode. Has a collapsible config panel for API keys and RSS feed management. Renders articles as `NewsCard` with Markdown support via `pulldown-cmark`. + +### How the frontend talks to the backend + +Every backend call uses the `invoke` wasm-bindgen extern: + +```rust +#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"], catch)] +async fn invoke(cmd: &str, args: JsValue) -> Result; +``` + +Arguments are serialized with `serde-wasm-bindgen` and results are deserialized back into typed Rust structs. + +--- + +## Backend (`src-tauri/src/`) + +Native Rust code that runs in the Tauri host process. Registered commands are the only API surface the frontend can call. + +### Entry point + +| File | Role | +|---|---| +| `src-tauri/src/main.rs` | Desktop executable entry point, calls `run()` | +| `src-tauri/src/lib.rs` | Configures Tauri: registers `NewsState`, registers all commands | + +### Modules + +#### `home.rs` — Nostr + +Handles the `fetch_nostr_posts` command. + +1. Generates a fresh, random throwaway keypair on every call (privacy by design — no persistent identity). +2. Creates an `EasyNostr` client using the temp key (via the local `easy-nostr` library). +3. Connects to three relays: `relay.damus.io`, `nos.lol`, `relay.snort.social`. +4. Fetches posts by hashtag list, or random posts if no tags are given. +5. Maps results to the serializable `LocalPost` struct and returns JSON. + +#### `news.rs` — RSS & AI News + +Manages `NewsState` (held in Tauri's managed state, shared across calls): + +``` +NewsState { + openrouter_key: Mutex + groq_key: Mutex + rss_config: Mutex +} +``` + +**Commands:** + +| Command | What it does | +|---|---| +| `load_rss_config` | Reads `rss.ron` from the app config dir; writes 16 default feeds if the file is missing or has fewer than 3 entries | +| `save_rss_urls` | Serializes the feed list to `rss.ron` and updates in-memory state | +| `save_openrouter_key` | Stores the key in `NewsState.openrouter_key` (in-memory only, not persisted to disk) | +| `save_groq_key` | Same for the Groq key | +| `fetch_ai_news` | POSTs to Groq or OpenRouter chat completions API; asks for a short tech news summary in German Markdown | +| `fetch_rss_news` | Iterates the in-memory feed list, fetches each feed with `reqwest`, parses with `feed-rs`, takes up to 3 articles per feed, returns `Vec` | + +### Local library: `easy-nostr` + +Located at `src-tauri/easy-nostr/` (path dependency). Wraps `nostr-sdk` to provide high-level helpers like `get_random_posts()` and `get_posts_by_hashtags()`. Written by the same author (Bytemalte). + +--- + +## Configuration & Persistence + +| What | Where | Format | +|---|---|---| +| RSS feed list | `/rss.ron` | RON | +| API keys | In-memory (`NewsState`) — lost on restart | — | +| App window config | `src-tauri/tauri.conf.json` | JSON | + +API keys are intentionally not persisted to disk. The user re-enters them each session via the config panel. + +--- + +## Build System + +| Tool | Role | +|---|---| +| `trunk` | Builds and bundles the Yew/Wasm frontend (`trunk build` / `trunk serve`) | +| `tauri-cli` | Wraps the full build and produces the native app bundle | +| `Trunk.toml` | Trunk configuration (output dir `dist/`, used by Tauri) | +| `justfile` | Developer shortcuts | + +The `tauri.conf.json` wires them together: +``` +beforeDevCommand: trunk serve (starts dev server on :1420) +beforeBuildCommand: trunk build (builds Wasm) +frontendDist: ../dist (Tauri embeds this) +``` + +### CI / Android + +`.gitea/workflows/android.yaml` runs on push to `main` or version tags. It: +1. Clones `easy-nostr` as a private submodule. +2. Installs Rust with four Android targets + `wasm32-unknown-unknown`. +3. Installs Trunk (binary) and `tauri-cli` (cargo). +4. Sets up Android SDK + NDK 25. +5. Runs `cargo tauri android build`. + +--- + +## Data Flow Summary + +``` +User action + │ + ▼ +Yew component (state update) + │ invoke("command_name", args_json) + ▼ +Tauri IPC bridge + │ + ▼ +Backend Rust function + │ network / file I/O + ▼ +External service (Nostr relay / RSS feed / AI API / disk) + │ serialized result + ▼ +Yew component re-renders +``` diff --git a/assets/animations.css b/assets/animations.css new file mode 100644 index 0000000..93fc707 --- /dev/null +++ b/assets/animations.css @@ -0,0 +1,37 @@ +/* --- Animations --- */ +@keyframes float { + 0%, + 100% { + transform: translateY(0px); + } + + 50% { + transform: translateY(-15px); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse-subtle { + 0% { + box-shadow: 0 0 0 0 rgba(74, 144, 226, 0.4); + } + + 70% { + box-shadow: 0 0 0 10px rgba(74, 144, 226, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(74, 144, 226, 0); + } +} diff --git a/assets/cards.css b/assets/cards.css new file mode 100644 index 0000000..3a6e633 --- /dev/null +++ b/assets/cards.css @@ -0,0 +1,50 @@ +/* --- Post & News Cards --- */ +.post-card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 18px; + padding: 16px; + margin-bottom: 16px; + word-wrap: break-word; +} + +.post-author { + color: var(--accent-color); + font-weight: 800; + font-size: 0.85rem; +} + +.post-meta { + display: flex; + align-items: center; + gap: 8px; +} + +.post-category-badge { + font-size: 0.7rem; + font-weight: 700; + color: var(--accent-color); + background: rgba(74, 144, 226, 0.12); + border: 1px solid rgba(74, 144, 226, 0.25); + padding: 2px 8px; + border-radius: 10px; +} + +/* --- Markdown Content --- */ +.markdown-body { + font-size: 0.95rem; + color: var(--text-primary); + line-height: 1.6; +} + +.markdown-body h3 { + margin-top: 0; + margin-bottom: 10px; + font-size: 1.15rem; + color: #ffffff; + font-weight: 700; +} + +.markdown-body p { + margin: 0; +} diff --git a/assets/config.css b/assets/config.css new file mode 100644 index 0000000..c161791 --- /dev/null +++ b/assets/config.css @@ -0,0 +1,196 @@ +/* --- Config Panel --- */ +.config-panel { + background: rgba(30, 30, 46, 0.6); + border: 1px solid rgba(74, 144, 226, 0.3); + border-radius: 20px; + padding: 20px; + margin-bottom: 30px; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4); + animation: slideIn 0.3s ease-out; +} + +@supports (backdrop-filter: blur(20px)) { + .config-panel { + backdrop-filter: blur(20px); + } +} + +.config-section { + margin-bottom: 20px; +} + +.config-section label { + display: block; + font-size: 0.7rem; + color: var(--text-secondary); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.config-input { + flex: 1; + padding: 14px 16px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + color: white; + border-radius: 12px; + font-size: 16px; + font-family: inherit; + transition: border-color 0.2s; +} + +.config-input:focus { + outline: none; + border-color: var(--accent-color); +} + +/* --- RSS Feed List --- */ +.rss-list-scroll { + max-height: 180px; + overflow-y: auto; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.05); + margin-bottom: 16px; +} + +.rss-url-entry { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + margin-bottom: 6px; + font-size: 0.85rem; + border: 1px solid transparent; + transition: all 0.2s; +} + +.rss-url-entry:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); +} + +.url-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 12px; + color: var(--text-secondary); +} + +.cat-badge { + font-size: 0.7rem; + font-weight: 700; + color: var(--accent-color); + background: rgba(74, 144, 226, 0.12); + border: 1px solid rgba(74, 144, 226, 0.25); + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; + margin: 0 8px; + flex-shrink: 0; +} + +/* --- RSS Add Row --- */ +.rss-input-row { + display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.rss-add-row { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 16px; +} + +.cat-select { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + color: white; + border-radius: 12px; + padding: 14px 10px; + font-size: 0.85rem; + font-family: inherit; + cursor: pointer; + flex-shrink: 0; + width: 130px; + transition: border-color 0.2s; +} + +.cat-select:focus { + outline: none; + border-color: var(--accent-color); +} + +.cat-select option { + background: #1e1e2e; + color: white; +} + +/* --- Buttons --- */ +.add-btn { + background: var(--accent-color); + color: white; + border: none; + border-radius: 12px; + width: 50px; + min-width: 50px; + cursor: pointer; + font-weight: 700; + font-size: 1.4rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.add-btn:active { + transform: scale(0.9); +} + +.delete-btn { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--danger); + cursor: pointer; + padding: 8px 12px; + border-radius: 8px; + font-weight: 700; + font-size: 0.8rem; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 36px; +} + +.delete-btn:active { + transform: scale(0.95); +} + +.delete-btn:hover { + background: var(--danger); + color: white; +} + +.save-master-btn { + background: linear-gradient(135deg, #4a90e2, #357abd); + color: white; + border: none; + padding: 16px; + border-radius: 12px; + cursor: pointer; + width: 100%; + font-weight: 700; + font-size: 1rem; + user-select: none; + min-height: 48px; + margin-top: 10px; +} diff --git a/assets/filters.css b/assets/filters.css new file mode 100644 index 0000000..93c357a --- /dev/null +++ b/assets/filters.css @@ -0,0 +1,102 @@ +/* --- Hashtag Filter (Home page) --- */ +.hashtag-section { + margin-bottom: 24px; +} + +.hashtag-input-row { + display: flex; + gap: 10px; +} + +.hashtag-input { + flex: 1; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 12px 16px; + color: white; + font-size: 0.95rem; + outline: none; + transition: all 0.2s; +} + +.hashtag-input:focus { + border-color: var(--accent-color); + background: rgba(255, 255, 255, 0.08); +} + +.tag-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.tag-chip { + background: rgba(74, 144, 226, 0.15); + color: var(--accent-color); + border: 1px solid rgba(74, 144, 226, 0.3); + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; +} + +.tag-chip:hover { + background: rgba(74, 144, 226, 0.25); + transform: scale(1.05); +} + +.tag-remove { + font-size: 1.1rem; + line-height: 1; + opacity: 0.6; +} + +.tag-chip:hover .tag-remove { + opacity: 1; +} + +/* --- Category Filter Bar (News page) --- */ +.category-bar { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 12px; + margin-bottom: 20px; + scrollbar-width: none; +} + +.category-bar::-webkit-scrollbar { + display: none; +} + +.cat-btn { + flex-shrink: 0; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + padding: 8px 16px; + border-radius: 20px; + cursor: pointer; + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + transition: all 0.2s ease; +} + +.cat-btn.active { + background: var(--accent-color); + border-color: var(--accent-color); + color: white; +} + +.cat-btn:hover:not(.active) { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); +} diff --git a/assets/greet.css b/assets/greet.css index e69de29..3edaf86 100644 --- a/assets/greet.css +++ b/assets/greet.css @@ -0,0 +1,36 @@ +/* --- Greet Page --- */ +.robot-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + margin: 40px auto; + width: 100%; + user-select: none; +} + +.robot-icon { + font-size: 5rem; + filter: drop-shadow(0 0 20px rgba(74, 144, 226, 0.3)); + animation: float 3s ease-in-out infinite; +} + +.bubble { + margin-top: 20px; + padding: 16px 24px; + background: rgba(30, 30, 46, 0.8); + border-radius: 20px; + border: 1px solid rgba(255, 173, 210, 0.3); + color: #ffffff; + font-weight: 600; + font-size: 1rem; + text-align: center; + max-width: 90%; +} + +@supports (backdrop-filter: blur(12px)) { + .bubble { + backdrop-filter: blur(12px); + } +} diff --git a/assets/header.css b/assets/header.css new file mode 100644 index 0000000..15d8175 --- /dev/null +++ b/assets/header.css @@ -0,0 +1,97 @@ +/* --- Feed Header & Controls --- */ +.feed-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; +} + +.header-main { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.header-main h1 { + margin: 0; + font-size: 1.5rem; +} + +.header-actions { + display: flex; + gap: 8px; + align-items: center; +} + +/* --- Mode Toggle (AI / RSS) --- */ +.mode-toggle { + display: flex; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 3px; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.toggle-btn { + border: none; + background: transparent; + color: #8e8e93; + padding: 8px 16px; + border-radius: 9px; + cursor: pointer; + font-size: 0.8rem; + font-weight: 700; + user-select: none; + min-height: 36px; +} + +.toggle-btn.active { + background: var(--accent-color); + color: white; +} + +/* --- Action Buttons (reload, settings) --- */ +.action-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 12px; + cursor: pointer; + font-size: 1.1rem; + user-select: none; + min-width: 44px; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +/* --- AI Provider Switch --- */ +.provider-switch { + display: flex; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 3px; + border: 1px solid rgba(255, 255, 255, 0.08); + margin: 0 10px; +} + +.prov-btn { + border: none; + background: transparent; + color: var(--text-secondary); + padding: 6px 14px; + border-radius: 9px; + cursor: pointer; + font-size: 0.75rem; + font-weight: 700; + transition: all 0.2s ease; +} + +.prov-btn.active { + background: var(--accent-color); + color: white; +} diff --git a/assets/layout.css b/assets/layout.css new file mode 100644 index 0000000..57f64a6 --- /dev/null +++ b/assets/layout.css @@ -0,0 +1,13 @@ +/* --- Layout --- */ +.feed-container { + width: 100%; + max-width: 600px; + padding: calc(20px + env(safe-area-inset-top)) 16px + calc(120px + env(safe-area-inset-bottom)) 16px; +} + +@media (min-width: 768px) { + .feed-container { + padding: 40px 20px 150px 20px; + } +} diff --git a/assets/navbar.css b/assets/navbar.css index e69de29..c6c769a 100644 --- a/assets/navbar.css +++ b/assets/navbar.css @@ -0,0 +1,48 @@ +/* --- Navigation Dock --- */ +.navbar-dock { + position: fixed; + bottom: calc(20px + env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%); + background-color: var(--nav-bg); + padding: 12px 24px; + border-radius: 50px; + border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + gap: 20px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); + z-index: 1000; + width: max-content; + max-width: 90%; +} + +@supports (backdrop-filter: blur(12px)) or (-webkit-backdrop-filter: blur(12px)) { + .navbar-dock { + background-color: rgba(30, 30, 46, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + } +} + +.nav-link { + color: var(--text-secondary); + text-decoration: none; + font-weight: 600; + font-size: 0.9rem; + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + padding: 10px 12px; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; +} + +.nav-link:hover { + color: #ffffff; + transform: translateY(-3px); +} + +.nav-link.active { + color: var(--accent-color); +} diff --git a/assets/utils.css b/assets/utils.css new file mode 100644 index 0000000..0ea2aa4 --- /dev/null +++ b/assets/utils.css @@ -0,0 +1,65 @@ +/* --- Utility & State Classes --- */ +.reload-btn-large { + cursor: pointer; + background: linear-gradient( + 135deg, + rgba(74, 144, 226, 0.15), + rgba(74, 144, 226, 0.05) + ); + color: var(--accent-color); + padding: 16px; + border-radius: 16px; + width: 100%; + font-weight: 700; + border: 1px solid rgba(74, 144, 226, 0.3); + user-select: none; + min-height: 52px; + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +.reload-btn-large:hover { + background: rgba(74, 144, 226, 0.2); + border-color: var(--accent-color); + box-shadow: 0 6px 20px rgba(74, 144, 226, 0.2); + transform: translateY(-2px); +} + +.reload-btn-large:active { + transform: scale(0.96) translateY(0); +} + +.error-box { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--danger); + padding: 15px; + border-radius: 12px; + text-align: center; +} + +.loading-spinner { + text-align: center; + padding: 40px; + color: var(--text-secondary); + font-style: italic; + animation: pulse-subtle 2s infinite; +} + +.empty-msg { + text-align: center; + color: var(--text-secondary); + margin-top: 40px; + font-size: 0.9rem; + opacity: 0.8; +} + +.status-msg { + text-align: center; + color: var(--text-secondary); + padding: 40px 16px; +} diff --git a/assets/variables.css b/assets/variables.css new file mode 100644 index 0000000..309b5f2 --- /dev/null +++ b/assets/variables.css @@ -0,0 +1,40 @@ +/* --- Root Variables & Reset --- */ +:root { + --bg-color: #0f0f13; + --card-bg: rgba(255, 255, 255, 0.03); + --card-border: rgba(255, 255, 255, 0.08); + --text-primary: #e2e2e7; + --text-secondary: #a0a0b0; + --accent-color: #4a90e2; + --nav-bg: rgba(30, 30, 46, 0.95); + --danger: #ef4444; +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +body { + background-color: var(--bg-color); + color: var(--text-primary); + font-family: + "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; + margin: 0; + line-height: 1.5; + display: flex; + justify-content: center; + min-height: 100vh; + overflow-x: hidden; + -webkit-tap-highlight-color: transparent; +} + +html { + scroll-behavior: smooth; + overflow-x: hidden; +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..a5049fe --- /dev/null +++ b/justfile @@ -0,0 +1,12 @@ +default: + just --list + +run: + cargo tauri dev + +build: + cargo tauri build --apk + +check: + cargo check + cargo clippy diff --git a/styles.css b/styles.css index 833b5f2..d3cd12c 100644 --- a/styles.css +++ b/styles.css @@ -1,690 +1,10 @@ -@import url("assets/home.css"); -@import url("assets/news.css"); +@import url("assets/variables.css"); +@import url("assets/animations.css"); +@import url("assets/layout.css"); +@import url("assets/navbar.css"); +@import url("assets/header.css"); +@import url("assets/cards.css"); +@import url("assets/config.css"); +@import url("assets/filters.css"); +@import url("assets/utils.css"); @import url("assets/greet.css"); - -/* --- Root Variables & Reset --- */ -:root { - --bg-color: #0f0f13; - --card-bg: rgba(255, 255, 255, 0.03); - --card-border: rgba(255, 255, 255, 0.08); - --text-primary: #e2e2e7; - --text-secondary: #a0a0b0; - --accent-color: #4a90e2; - --nav-bg: rgba(30, 30, 46, 0.95); - /* Fallback for blur */ - --danger: #ef4444; -} - -* { - box-sizing: border-box; - -webkit-tap-highlight-color: transparent; - /* UX-Finishing: No blue flash */ -} - -/* --- Global & Layout --- */ -body { - background-color: var(--bg-color); - color: var(--text-primary); - font-family: - "Inter", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - sans-serif; - margin: 0; - line-height: 1.5; - display: flex; - justify-content: center; - min-height: 100vh; - overflow-x: hidden; - /* Performance: Prevent horizontal scroll */ - -webkit-tap-highlight-color: transparent; -} - -html { - scroll-behavior: smooth; - overflow-x: hidden; -} - -.feed-container { - width: 100%; - max-width: 600px; - /* Safe Areas: Top inset and mobile-friendly padding */ - padding: calc(20px + env(safe-area-inset-top)) 16px - calc(120px + env(safe-area-inset-bottom)) 16px; -} - -@media (min-width: 768px) { - .feed-container { - padding: 40px 20px 150px 20px; - } -} - -/* --- Navigation Dock --- */ -.navbar-dock { - position: fixed; - /* Safe Areas: Bottom inset */ - bottom: calc(20px + env(safe-area-inset-bottom)); - left: 50%; - transform: translateX(-50%); - background-color: var(--nav-bg); - padding: 12px 24px; - border-radius: 50px; - border: 1px solid rgba(255, 255, 255, 0.1); - display: flex; - gap: 20px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); - z-index: 1000; - width: max-content; - max-width: 90%; -} - -@supports (backdrop-filter: blur(12px)) or (-webkit-backdrop-filter: blur(12px)) { - .navbar-dock { - background-color: rgba(30, 30, 46, 0.85); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - } -} - -.nav-link { - color: var(--text-secondary); - text-decoration: none; - font-weight: 600; - font-size: 0.9rem; - transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); - /* Touch-Ergonomie: Min 44px effective height */ - padding: 10px 12px; - user-select: none; - /* UX-Finishing */ - display: flex; - align-items: center; - justify-content: center; - min-height: 44px; -} - -.nav-link:hover { - color: #ffffff; - transform: translateY(-3px); -} - -.nav-link.active { - color: var(--accent-color); -} - -/* --- Header & Navigation --- */ -.feed-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 24px; - flex-wrap: wrap; - gap: 16px; -} - -.header-main h1 { - margin: 0; - font-size: 1.5rem; -} - -.mode-toggle { - display: flex; - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; - padding: 3px; - border: 1px solid rgba(255, 255, 255, 0.08); -} - -.toggle-btn { - border: none; - background: transparent; - color: #8e8e93; - padding: 8px 16px; - border-radius: 9px; - cursor: pointer; - font-size: 0.8rem; - font-weight: 700; - user-select: none; - min-height: 36px; -} - -.toggle-btn.active { - background: var(--accent-color); - color: white; -} - -.action-btn { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 12px; - padding: 12px; - cursor: pointer; - font-size: 1.1rem; - user-select: none; - min-width: 44px; - min-height: 44px; - display: flex; - align-items: center; - justify-content: center; -} - -/* --- Config Panel --- */ -.config-panel { - background: rgba(30, 30, 46, 0.6); - border: 1px solid rgba(74, 144, 226, 0.3); - border-radius: 20px; - padding: 20px; - margin-bottom: 30px; - box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4); - animation: slideIn 0.3s ease-out; -} - -@supports (backdrop-filter: blur(20px)) { - .config-panel { - backdrop-filter: blur(20px); - } -} - -.config-input { - flex: 1; - padding: 14px 16px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.1); - color: white; - border-radius: 12px; - /* Input-Fix: Prevent Android Auto-Zoom */ - font-size: 16px; - font-family: inherit; - transition: border-color 0.2s; -} - -.config-input:focus { - outline: none; - border-color: var(--accent-color); -} - -.rss-input-row { - display: flex; - gap: 10px; - margin-bottom: 16px; -} - -.add-btn { - background: var(--accent-color); - color: white; - border: none; - border-radius: 12px; - width: 50px; - min-width: 50px; - cursor: pointer; - font-weight: 700; - font-size: 1.4rem; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -.add-btn:active { - transform: scale(0.9); -} - -.rss-list-scroll { - max-height: 180px; - overflow-y: auto; - background: rgba(0, 0, 0, 0.2); - border-radius: 12px; - padding: 8px; - border: 1px solid rgba(255, 255, 255, 0.05); - margin-bottom: 16px; -} - -.rss-url-entry { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 14px; - background: rgba(255, 255, 255, 0.03); - border-radius: 10px; - margin-bottom: 6px; - font-size: 0.85rem; - border: 1px solid transparent; - transition: all 0.2s; -} - -.rss-url-entry:hover { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.1); -} - -.url-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-right: 12px; - color: var(--text-secondary); -} - -.delete-btn { - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.2); - color: var(--danger); - cursor: pointer; - padding: 8px 12px; - border-radius: 8px; - font-weight: 700; - font-size: 0.8rem; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - min-width: 44px; - min-height: 36px; -} - -.delete-btn:active { - transform: scale(0.95); -} - -.delete-btn:hover { - background: var(--danger); - color: white; -} - -.save-master-btn { - background: linear-gradient(135deg, #4a90e2, #357abd); - color: white; - border: none; - padding: 16px; - border-radius: 12px; - cursor: pointer; - width: 100%; - font-weight: 700; - font-size: 1rem; - user-select: none; - min-height: 48px; - margin-top: 10px; -} - -/* --- Post Cards --- */ -.post-card { - background: var(--card-bg); - border: 1px solid var(--card-border); - border-radius: 18px; - padding: 16px; - margin-bottom: 16px; - word-wrap: break-word; -} - -.post-author { - color: var(--accent-color); - font-weight: 800; - font-size: 0.85rem; -} - -/* --- Greet Page --- */ -.robot-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - cursor: pointer; - margin: 40px auto; - width: 100%; - user-select: none; -} - -.robot-icon { - font-size: 5rem; - filter: drop-shadow(0 0 20px rgba(74, 144, 226, 0.3)); - animation: float 3s ease-in-out infinite; -} - -.bubble { - margin-top: 20px; - padding: 16px 24px; - background: rgba(30, 30, 46, 0.8); - border-radius: 20px; - border: 1px solid rgba(255, 173, 210, 0.3); - color: #ffffff; - font-weight: 600; - font-size: 1rem; - text-align: center; - max-width: 90%; -} - -@supports (backdrop-filter: blur(12px)) { - .bubble { - backdrop-filter: blur(12px); - } -} - -/* --- Animations --- */ -@keyframes float { - 0%, - 100% { - transform: translateY(0px); - } - - 50% { - transform: translateY(-15px); - } -} - -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(-10px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -/* --- Utilities --- */ -.reload-btn-large { - cursor: pointer; - background: linear-gradient( - 135deg, - rgba(74, 144, 226, 0.15), - rgba(74, 144, 226, 0.05) - ); - color: var(--accent-color); - padding: 16px; - border-radius: 16px; - width: 100%; - font-weight: 700; - border: 1px solid rgba(74, 144, 226, 0.3); - user-select: none; - min-height: 52px; - transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); -} - -.reload-btn-large:hover { - background: rgba(74, 144, 226, 0.2); - border-color: var(--accent-color); - box-shadow: 0 6px 20px rgba(74, 144, 226, 0.2); - transform: translateY(-2px); -} - -.reload-btn-large:active { - transform: scale(0.96) translateY(0); -} - -/* Optional: Add a subtle pulse animation when content is old */ -@keyframes pulse-subtle { - 0% { - box-shadow: 0 0 0 0 rgba(74, 144, 226, 0.4); - } - - 70% { - box-shadow: 0 0 0 10px rgba(74, 144, 226, 0); - } - - 100% { - box-shadow: 0 0 0 0 rgba(74, 144, 226, 0); - } -} - -.error-box { - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); - color: var(--danger); - padding: 15px; - border-radius: 12px; - text-align: center; -} - -/* --- AI Provider Switch (Neu) --- */ -.provider-switch { - display: flex; - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; - padding: 3px; - border: 1px solid rgba(255, 255, 255, 0.08); - /* Zwischen h1 und den Icons positionieren */ - margin: 0 10px; -} - -.prov-btn { - border: none; - background: transparent; - color: var(--text-secondary); - padding: 6px 14px; - border-radius: 9px; - cursor: pointer; - font-size: 0.75rem; - font-weight: 700; - transition: all 0.2s ease; -} - -.prov-btn.active { - background: var(--accent-color); - color: white; -} - -/* --- Config Section Refinement --- */ -.config-section { - margin-bottom: 20px; -} - -.config-section label { - display: block; - font-size: 0.7rem; - color: var(--text-secondary); - margin-bottom: 6px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* --- Markdown Content Styling --- */ -.markdown-body { - font-size: 0.95rem; - color: var(--text-primary); - line-height: 1.6; -} - -.markdown-body h3 { - margin-top: 0; - margin-bottom: 10px; - font-size: 1.15rem; - color: #ffffff; - font-weight: 700; -} - -.markdown-body p { - margin: 0; -} - -/* --- Status Indicators --- */ -.loading-spinner { - text-align: center; - padding: 40px; - color: var(--text-secondary); - font-style: italic; - animation: pulse-subtle 2s infinite; -} - -.empty-msg { - text-align: center; - color: var(--text-secondary); - margin-top: 40px; - font-size: 0.9rem; - opacity: 0.8; -} - -/* --- Hashtag Section --- */ -.hashtag-section { - margin-bottom: 24px; -} - -.hashtag-input-row { - display: flex; - gap: 10px; -} - -.hashtag-input { - flex: 1; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 12px; - padding: 12px 16px; - color: white; - font-size: 0.95rem; - outline: none; - transition: all 0.2s; -} - -.hashtag-input:focus { - border-color: var(--accent-color); - background: rgba(255, 255, 255, 0.08); -} - -.tag-chips { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 12px; -} - -.tag-chip { - background: rgba(74, 144, 226, 0.15); - color: var(--accent-color); - border: 1px solid rgba(74, 144, 226, 0.3); - padding: 4px 12px; - border-radius: 20px; - font-size: 0.85rem; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.2s; -} - -.tag-chip:hover { - background: rgba(74, 144, 226, 0.25); - transform: scale(1.05); -} - -.tag-remove { - font-size: 1.1rem; - line-height: 1; - opacity: 0.6; -} - -.tag-chip:hover .tag-remove { - opacity: 1; -} - -/* --- Layout Adjustment für Provider Switch --- */ -.header-main { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; -} - -/* --- Category filter --- */ -/* ── Kategorie-Filterleiste ── */ -.category-bar { - display: flex; - gap: 8px; - overflow-x: auto; - padding-bottom: 12px; - margin-bottom: 20px; - scrollbar-width: none; -} -.category-bar::-webkit-scrollbar { - display: none; -} - -.cat-btn { - flex-shrink: 0; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - color: var(--text-secondary); - padding: 8px 16px; - border-radius: 20px; - cursor: pointer; - font-size: 0.8rem; - font-weight: 600; - white-space: nowrap; - transition: all 0.2s ease; -} -.cat-btn.active { - background: var(--accent-color); - border-color: var(--accent-color); - color: white; -} -.cat-btn:hover:not(.active) { - background: rgba(255, 255, 255, 0.1); - color: var(--text-primary); -} - -/* ── Kategorie-Badge im Feed-Eintrag ── */ -.cat-badge { - font-size: 0.7rem; - font-weight: 700; - color: var(--accent-color); - background: rgba(74, 144, 226, 0.12); - border: 1px solid rgba(74, 144, 226, 0.25); - padding: 2px 8px; - border-radius: 10px; - white-space: nowrap; - margin: 0 8px; - flex-shrink: 0; -} - -/* ── Kategorie-Badge auf NewsCard ── */ -.post-meta { - display: flex; - align-items: center; - gap: 8px; -} -.post-category-badge { - font-size: 0.7rem; - font-weight: 700; - color: var(--accent-color); - background: rgba(74, 144, 226, 0.12); - border: 1px solid rgba(74, 144, 226, 0.25); - padding: 2px 8px; - border-radius: 10px; -} - -/* ── Neue URL-Zeile mit Kategorie-Dropdown ── */ -.rss-add-row { - display: flex; - gap: 8px; - align-items: center; - margin-bottom: 16px; -} -.cat-select { - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.1); - color: white; - border-radius: 12px; - padding: 14px 10px; - font-size: 0.85rem; - font-family: inherit; - cursor: pointer; - flex-shrink: 0; - width: 130px; - transition: border-color 0.2s; -} -.cat-select:focus { - outline: none; - border-color: var(--accent-color); -} -.cat-select option { - background: #1e1e2e; - color: white; -}