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 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 15:02:36 +01:00
parent ef98a11bb8
commit 0d531d5b7c
14 changed files with 1065 additions and 689 deletions

186
AGENTS.md Normal file
View File

@@ -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! { <YourPage /> },
```
5. Add a `<Link<Route>>` 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<JsValue, JsValue>;
}
```
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<T>` — 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 `<app_config_dir>/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<String>` fields in `NewsState`. They are lost when the app restarts. There is no persistence for keys by design.

174
ARCHITECTURE.md Normal file
View File

@@ -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<JsValue, JsValue>;
```
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<String>
groq_key: Mutex<String>
rss_config: Mutex<RssConfig>
}
```
**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<NewsArticle>` |
### 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 | `<app_config_dir>/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
```

37
assets/animations.css Normal file
View File

@@ -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);
}
}

50
assets/cards.css Normal file
View File

@@ -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;
}

196
assets/config.css Normal file
View File

@@ -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;
}

102
assets/filters.css Normal file
View File

@@ -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);
}

View File

@@ -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);
}
}

97
assets/header.css Normal file
View File

@@ -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;
}

13
assets/layout.css Normal file
View File

@@ -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;
}
}

View File

@@ -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);
}

65
assets/utils.css Normal file
View File

@@ -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;
}

40
assets/variables.css Normal file
View File

@@ -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;
}

12
justfile Normal file
View File

@@ -0,0 +1,12 @@
default:
just --list
run:
cargo tauri dev
build:
cargo tauri build --apk
check:
cargo check
cargo clippy

View File

@@ -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;
}