Compare commits
10 Commits
v0.2.0
...
update/doc
| Author | SHA1 | Date | |
|---|---|---|---|
| a91537fccf | |||
| 0d531d5b7c | |||
| ef98a11bb8 | |||
| 9518385718 | |||
| 7cc483b54c | |||
| fdae53b7eb | |||
| 53e5b3ba93 | |||
| 3012f84663 | |||
| c7a0272df3 | |||
| 1e1614b96d |
@@ -1,8 +1,8 @@
|
|||||||
name: Android Build Final Fixed
|
name: Android Universal Build
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"] # Triggert bei v0.1.0, v1.0, etc.
|
tags: ["v*"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android:
|
build-android:
|
||||||
@@ -31,13 +31,16 @@ jobs:
|
|||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
rustup target add aarch64-linux-android wasm32-unknown-unknown
|
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
- name: Install Trunk & Tauri-CLI
|
- name: Install Trunk & Tauri-CLI (Fast & Fixed)
|
||||||
run: |
|
run: |
|
||||||
|
# Trunk via Binary (schneller als Cargo compile)
|
||||||
wget -qO- https://github.com/trunk-rs/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf- -C /usr/local/bin
|
wget -qO- https://github.com/trunk-rs/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf- -C /usr/local/bin
|
||||||
wget -qO- https://github.com/tauri-apps/tauri/releases/latest/download/cargo-tauri-x86_64-unknown-linux-gnu.tgz | tar -xzf- -C /usr/local/bin
|
# Tauri CLI v2 via Cargo (sicherer für v2)
|
||||||
chmod +x /usr/local/bin/trunk /usr/local/bin/cargo-tauri
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
cargo install tauri-cli --version "^2.0.0"
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
run: |
|
run: |
|
||||||
@@ -63,15 +66,23 @@ jobs:
|
|||||||
|
|
||||||
# 2. Android Build
|
# 2. Android Build
|
||||||
if [ ! -d "src-tauri/gen/android" ]; then
|
if [ ! -d "src-tauri/gen/android" ]; then
|
||||||
cargo-tauri android init
|
cargo tauri android init
|
||||||
fi
|
fi
|
||||||
cargo-tauri android build --target aarch64 --apk true
|
|
||||||
|
# FIX: Bei Android Build v2 ist --release implizit oder wird nicht als Flag akzeptiert
|
||||||
|
# Wir nutzen genau den Befehl, der lokal bei dir funktioniert hat:
|
||||||
|
cargo tauri android build --apk
|
||||||
|
|
||||||
# 3. APK manuell signieren
|
# 3. APK manuell signieren
|
||||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | tr -d '[:space:]' > keystore.b64
|
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | tr -d '[:space:]' > keystore.b64
|
||||||
base64 -d keystore.b64 > release.keystore
|
base64 -d keystore.b64 > release.keystore
|
||||||
|
|
||||||
UNSIGNED_APK=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name "*-unsigned.apk" | head -n 1)
|
UNSIGNED_APK=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name "app-universal-release-unsigned.apk" | head -n 1)
|
||||||
|
|
||||||
|
if [ -z "$UNSIGNED_APK" ]; then
|
||||||
|
UNSIGNED_APK="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk"
|
||||||
|
fi
|
||||||
|
|
||||||
APKSIGNER=$(find $ANDROID_HOME/build-tools -name apksigner | sort -r | head -n 1)
|
APKSIGNER=$(find $ANDROID_HOME/build-tools -name apksigner | sort -r | head -n 1)
|
||||||
|
|
||||||
$APKSIGNER sign --ks release.keystore \
|
$APKSIGNER sign --ks release.keystore \
|
||||||
@@ -80,7 +91,7 @@ jobs:
|
|||||||
--ks-pass pass:"${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
|
--ks-pass pass:"${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
|
||||||
--key-pass pass:"${{ secrets.ANDROID_KEY_PASSWORD }}" \
|
--key-pass pass:"${{ secrets.ANDROID_KEY_PASSWORD }}" \
|
||||||
--v4-signing-enabled true \
|
--v4-signing-enabled true \
|
||||||
--out Marstemedia-Signed.apk \
|
--out Marstemedia-Universal-Signed.apk \
|
||||||
"$UNSIGNED_APK"
|
"$UNSIGNED_APK"
|
||||||
|
|
||||||
echo "Signierung erfolgreich!"
|
echo "Signierung erfolgreich!"
|
||||||
@@ -90,15 +101,14 @@ jobs:
|
|||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: Marstemedia-Signed
|
name: Marstemedia-Universal-Signed
|
||||||
path: Marstemedia-Signed.apk
|
path: Marstemedia-Universal-Signed.apk
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: Marstemedia-Signed.apk
|
files: Marstemedia-Universal-Signed.apk
|
||||||
# Gitea braucht manchmal explizit den Namen/Body
|
|
||||||
name: "Release ${{ github.ref_name }}"
|
name: "Release ${{ github.ref_name }}"
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
186
AGENTS.md
Normal file
186
AGENTS.md
Normal 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
174
ARCHITECTURE.md
Normal 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
|
||||||
|
```
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "marstemedia-ui"
|
name = "marstemedia-ui"
|
||||||
version = "0.1.0"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
37
assets/animations.css
Normal file
37
assets/animations.css
Normal 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
50
assets/cards.css
Normal 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
196
assets/config.css
Normal 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
102
assets/filters.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
97
assets/header.css
Normal 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
13
assets/layout.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
65
assets/utils.css
Normal 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
40
assets/variables.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<title>Tauri + Yew App</title>
|
<title>Tauri + Yew App</title>
|
||||||
<link data-trunk rel="css" href="styles.css" />
|
<link data-trunk rel="css" href="styles.css" />
|
||||||
<link data-trunk rel="copy-dir" href="public" />
|
<link data-trunk rel="copy-dir" href="public" />
|
||||||
|
<link data-trunk rel="copy-dir" href="assets" />
|
||||||
</head>
|
</head>
|
||||||
<body></body>
|
<body></body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
12
justfile
Normal file
12
justfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
default:
|
||||||
|
just --list
|
||||||
|
|
||||||
|
run:
|
||||||
|
cargo tauri dev
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo tauri build --apk
|
||||||
|
|
||||||
|
check:
|
||||||
|
cargo check
|
||||||
|
cargo clippy
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "marstemedia"
|
name = "marstemedia"
|
||||||
version = "0.1.0"
|
version = "0.5.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
Submodule src-tauri/easy-nostr updated: 7cebc490ca...9845dd025d
@@ -11,9 +11,9 @@ pub struct LocalPost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn fetch_nostr_posts() -> Result<Vec<LocalPost>, String> {
|
pub async fn fetch_nostr_posts(hashtags: Vec<String>) -> Result<Vec<LocalPost>, String> {
|
||||||
|
println!("Fetching Nostr posts for hashtags: {:?}", hashtags);
|
||||||
// 1. Temporären Einweg-Schlüssel generieren
|
// 1. Temporären Einweg-Schlüssel generieren
|
||||||
// Das erzeugt ein Schlüsselpaar im RAM, das nach dem Funktionsaufruf verschwindet.
|
|
||||||
let random_keys = Keys::generate();
|
let random_keys = Keys::generate();
|
||||||
let temp_nsec = random_keys
|
let temp_nsec = random_keys
|
||||||
.secret_key()
|
.secret_key()
|
||||||
@@ -34,8 +34,14 @@ pub async fn fetch_nostr_posts() -> Result<Vec<LocalPost>, String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 4. Posts von der Library holen
|
// 4. Posts von der Library holen - Entweder per Hashtag oder Random
|
||||||
let raw_posts = easy.get_random_posts().await.map_err(|e| e.to_string())?;
|
let raw_posts = if hashtags.is_empty() {
|
||||||
|
easy.get_random_posts().await.map_err(|e| e.to_string())?
|
||||||
|
} else {
|
||||||
|
easy.get_posts_by_hashtags(hashtags)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
// 5. Mappen: Library-Typ -> Unser serialisierbarer Typ
|
// 5. Mappen: Library-Typ -> Unser serialisierbarer Typ
|
||||||
let mapped_posts = raw_posts
|
let mapped_posts = raw_posts
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ use tauri::{AppHandle, Manager, State};
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||||
pub struct RssConfig {
|
pub struct RssConfig {
|
||||||
pub urls: Vec<String>,
|
pub feeds: Vec<RssFeed>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct RssFeed {
|
||||||
|
pub url: String,
|
||||||
|
pub category: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -22,6 +28,7 @@ pub struct NewsArticle {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
pub author: String,
|
pub author: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub category: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_config_path(app: &AppHandle) -> PathBuf {
|
fn get_config_path(app: &AppHandle) -> PathBuf {
|
||||||
@@ -31,6 +38,81 @@ fn get_config_path(app: &AppHandle) -> PathBuf {
|
|||||||
.join("rss.ron")
|
.join("rss.ron")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_feeds() -> Vec<RssFeed> {
|
||||||
|
vec![
|
||||||
|
// 💻 Technik
|
||||||
|
RssFeed {
|
||||||
|
url: "https://www.heise.de/rss/heise-atom.xml".into(),
|
||||||
|
category: "Technik".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://www.golem.de/rss.php?feed=ATOM1.0".into(),
|
||||||
|
category: "Technik".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://www.theverge.com/rss/index.xml".into(),
|
||||||
|
category: "Technik".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://arstechnica.com/feed/".into(),
|
||||||
|
category: "Technik".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://www.phoronix.com/rss.php".into(),
|
||||||
|
category: "Technik".into(),
|
||||||
|
},
|
||||||
|
// 📰 Nachrichten
|
||||||
|
RssFeed {
|
||||||
|
url: "https://www.tagesschau.de/xml/rss2".into(),
|
||||||
|
category: "Nachrichten".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://www.spiegel.de/schlagzeilen/index.rss".into(),
|
||||||
|
category: "Nachrichten".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://feeds.bbci.co.uk/news/technology/rss.xml".into(),
|
||||||
|
category: "Nachrichten".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://feeds.reuters.com/reuters/technologyNews".into(),
|
||||||
|
category: "Nachrichten".into(),
|
||||||
|
},
|
||||||
|
// 🔭 Wissenschaft
|
||||||
|
RssFeed {
|
||||||
|
url: "https://www.nasa.gov/news-release/feed/".into(),
|
||||||
|
category: "Wissenschaft".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://www.sciencedaily.com/rss/computers_math/artificial_intelligence.xml"
|
||||||
|
.into(),
|
||||||
|
category: "Wissenschaft".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://deepmind.google/blog/rss.xml".into(),
|
||||||
|
category: "Wissenschaft".into(),
|
||||||
|
},
|
||||||
|
// 🤖 KI
|
||||||
|
RssFeed {
|
||||||
|
url: "https://openai.com/blog/rss.xml".into(),
|
||||||
|
category: "KI".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://venturebeat.com/feed/".into(),
|
||||||
|
category: "KI".into(),
|
||||||
|
},
|
||||||
|
// 🐧 Open Source
|
||||||
|
RssFeed {
|
||||||
|
url: "https://lwn.net/headlines/rss".into(),
|
||||||
|
category: "Open Source".into(),
|
||||||
|
},
|
||||||
|
RssFeed {
|
||||||
|
url: "https://github.blog/feed/".into(),
|
||||||
|
category: "Open Source".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_openrouter_key(key: String, state: State<'_, NewsState>) -> Result<(), String> {
|
pub async fn save_openrouter_key(key: String, state: State<'_, NewsState>) -> Result<(), String> {
|
||||||
let mut lock = state.openrouter_key.lock().map_err(|_| "Lock failed")?;
|
let mut lock = state.openrouter_key.lock().map_err(|_| "Lock failed")?;
|
||||||
@@ -49,39 +131,45 @@ pub async fn save_groq_key(key: String, state: State<'_, NewsState>) -> Result<(
|
|||||||
pub async fn load_rss_config(
|
pub async fn load_rss_config(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, NewsState>,
|
state: State<'_, NewsState>,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<RssFeed>, String> {
|
||||||
let path = get_config_path(&app);
|
let path = get_config_path(&app);
|
||||||
if !path.exists() {
|
let defaults = default_feeds();
|
||||||
let default_urls = vec![
|
|
||||||
"https://www.nasa.gov/news-release/feed/".to_string(),
|
let existing_config: Option<RssConfig> = if path.exists() {
|
||||||
"https://www.heise.de/rss/heise-atom.xml".to_string(),
|
let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
];
|
ron::from_str(&content).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Datei fehlt ODER hat weniger als 3 Einträge → defaults schreiben
|
||||||
|
let feeds = match existing_config {
|
||||||
|
Some(config) if config.feeds.len() >= 3 => config.feeds,
|
||||||
|
_ => {
|
||||||
let config = RssConfig {
|
let config = RssConfig {
|
||||||
urls: default_urls.clone(),
|
feeds: defaults.clone(),
|
||||||
};
|
};
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
let ron_str = ron::to_string(&config).map_err(|e| e.to_string())?;
|
let ron_str = ron::to_string(&config).map_err(|e| e.to_string())?;
|
||||||
fs::write(&path, ron_str).map_err(|e| e.to_string())?;
|
fs::write(&path, ron_str).map_err(|e| e.to_string())?;
|
||||||
let mut lock = state.rss_config.lock().unwrap();
|
defaults
|
||||||
lock.urls = default_urls.clone();
|
|
||||||
return Ok(default_urls);
|
|
||||||
}
|
}
|
||||||
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
};
|
||||||
let config: RssConfig = ron::from_str(&content).map_err(|e| e.to_string())?;
|
|
||||||
let mut lock = state.rss_config.lock().unwrap();
|
let mut lock = state.rss_config.lock().unwrap();
|
||||||
*lock = config.clone();
|
lock.feeds = feeds.clone();
|
||||||
Ok(config.urls)
|
Ok(feeds)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_rss_urls(
|
pub async fn save_rss_urls(
|
||||||
urls: Vec<String>,
|
feeds: Vec<RssFeed>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, NewsState>,
|
state: State<'_, NewsState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let config = RssConfig { urls };
|
let config = RssConfig { feeds };
|
||||||
let path = get_config_path(&app);
|
let path = get_config_path(&app);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
@@ -152,17 +240,18 @@ pub async fn fetch_ai_news(
|
|||||||
content,
|
content,
|
||||||
author: author.into(),
|
author: author.into(),
|
||||||
created_at: "Gerade eben".into(),
|
created_at: "Gerade eben".into(),
|
||||||
|
category: "KI".into(),
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn fetch_rss_news(state: State<'_, NewsState>) -> Result<Vec<NewsArticle>, String> {
|
pub async fn fetch_rss_news(state: State<'_, NewsState>) -> Result<Vec<NewsArticle>, String> {
|
||||||
let urls = state.rss_config.lock().unwrap().urls.clone();
|
let feeds = state.rss_config.lock().unwrap().feeds.clone();
|
||||||
let mut all_articles = Vec::new();
|
let mut all_articles = Vec::new();
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
for url in urls {
|
for feed_cfg in feeds {
|
||||||
if let Ok(res) = client.get(&url).send().await {
|
if let Ok(res) = client.get(&feed_cfg.url).send().await {
|
||||||
if let Ok(bytes) = res.bytes().await {
|
if let Ok(bytes) = res.bytes().await {
|
||||||
if let Ok(feed) = feed_rs::parser::parse(&bytes[..]) {
|
if let Ok(feed) = feed_rs::parser::parse(&bytes[..]) {
|
||||||
let source = feed
|
let source = feed
|
||||||
@@ -184,6 +273,7 @@ pub async fn fetch_rss_news(state: State<'_, NewsState>) -> Result<Vec<NewsArtic
|
|||||||
),
|
),
|
||||||
author: source.clone(),
|
author: source.clone(),
|
||||||
created_at: date,
|
created_at: date,
|
||||||
|
category: feed_cfg.category.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "marstemedia",
|
"productName": "marstemedia",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"identifier": "malxte.de",
|
"identifier": "malxte.de",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "trunk serve",
|
"beforeDevCommand": "trunk serve",
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use yew::prelude::*;
|
|||||||
// Tauri 'invoke' Funktion deklarieren
|
// Tauri 'invoke' Funktion deklarieren
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"])]
|
#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"], catch)]
|
||||||
async fn invoke(cmd: &str, args: JsValue) -> JsValue;
|
async fn invoke(cmd: &str, args: JsValue) -> Result<JsValue, JsValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wir definieren Post lokal, damit Yew weiß, wie die JSON-Daten vom Backend aussehen
|
// Wir definieren Post lokal, damit Yew weiß, wie die JSON-Daten vom Backend aussehen
|
||||||
@@ -24,13 +24,17 @@ pub struct Post {
|
|||||||
pub fn home() -> Html {
|
pub fn home() -> Html {
|
||||||
let posts = use_state(|| None::<Vec<Post>>);
|
let posts = use_state(|| None::<Vec<Post>>);
|
||||||
let error = use_state(|| false);
|
let error = use_state(|| false);
|
||||||
|
let hashtag_input = use_state(|| String::new());
|
||||||
|
let active_tags = use_state(Vec::<String>::new);
|
||||||
|
|
||||||
let load_posts = {
|
let load_posts = {
|
||||||
let posts = posts.clone();
|
let posts = posts.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
|
let active_tags = active_tags.clone();
|
||||||
Callback::from(move |_e: MouseEvent| {
|
Callback::from(move |_e: MouseEvent| {
|
||||||
let posts = posts.clone();
|
let posts = posts.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
|
let tags = (*active_tags).clone();
|
||||||
|
|
||||||
// UI zurücksetzen & Scrollen
|
// UI zurücksetzen & Scrollen
|
||||||
posts.set(None);
|
posts.set(None);
|
||||||
@@ -43,15 +47,26 @@ pub fn home() -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
// Daten vom Rust-Backend anfordern
|
#[derive(Serialize)]
|
||||||
let res = invoke("fetch_nostr_posts", JsValue::NULL).await;
|
struct HashtagArgs {
|
||||||
|
hashtags: Vec<String>,
|
||||||
|
}
|
||||||
|
let args = serde_wasm_bindgen::to_value(&HashtagArgs { hashtags: tags }).unwrap();
|
||||||
|
|
||||||
|
// Daten vom Rust-Backend anfordern
|
||||||
|
match invoke("fetch_nostr_posts", args).await {
|
||||||
|
Ok(res) => {
|
||||||
// JSON-Resultat in Vec<Post> umwandeln
|
// JSON-Resultat in Vec<Post> umwandeln
|
||||||
if let Ok(new_posts) = serde_wasm_bindgen::from_value::<Vec<Post>>(res) {
|
if let Ok(new_posts) = serde_wasm_bindgen::from_value::<Vec<Post>>(res) {
|
||||||
posts.set(Some(new_posts));
|
posts.set(Some(new_posts));
|
||||||
} else {
|
} else {
|
||||||
error.set(true);
|
error.set(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
error.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -68,11 +83,74 @@ pub fn home() -> Html {
|
|||||||
html! {
|
html! {
|
||||||
<div class="feed-container">
|
<div class="feed-container">
|
||||||
<div class="feed-header">
|
<div class="feed-header">
|
||||||
|
<div class="header-main">
|
||||||
<h1 class="feed-title">{"✨ Entdecken"}</h1>
|
<h1 class="feed-title">{"✨ Entdecken"}</h1>
|
||||||
<button class="reload-btn" onclick={load_posts.clone()}>
|
<button class="reload-btn" onclick={load_posts.clone()}>
|
||||||
{"🔄"}
|
{"🔄"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hashtag-section">
|
||||||
|
<div class="hashtag-input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="hashtag-input"
|
||||||
|
placeholder="Tags hinzufügen (z.B. bitcoin, rust)..."
|
||||||
|
value={(*hashtag_input).clone()}
|
||||||
|
oninput={
|
||||||
|
let hashtag_input = hashtag_input.clone();
|
||||||
|
move |e: InputEvent| {
|
||||||
|
let target: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||||
|
hashtag_input.set(target.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onkeydown={
|
||||||
|
let hashtag_input = hashtag_input.clone();
|
||||||
|
let active_tags = active_tags.clone();
|
||||||
|
let load_posts = load_posts.clone();
|
||||||
|
move |e: KeyboardEvent| {
|
||||||
|
if e.key() == "Enter" {
|
||||||
|
let val = (*hashtag_input).trim().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
let mut tags = (*active_tags).clone();
|
||||||
|
// Mehrere Tags per Komma trennen
|
||||||
|
for t in val.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||||
|
if !tags.contains(&t.to_string()) {
|
||||||
|
tags.push(t.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
active_tags.set(tags);
|
||||||
|
hashtag_input.set(String::new());
|
||||||
|
load_posts.emit(MouseEvent::new("click").unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
if !active_tags.is_empty() {
|
||||||
|
<div class="tag-chips">
|
||||||
|
{ for active_tags.iter().map(|t| {
|
||||||
|
let t_clone = t.clone();
|
||||||
|
let active_tags = active_tags.clone();
|
||||||
|
let load_posts = load_posts.clone();
|
||||||
|
let remove = move |_| {
|
||||||
|
let mut tags = (*active_tags).clone();
|
||||||
|
tags.retain(|x| x != &t_clone);
|
||||||
|
active_tags.set(tags);
|
||||||
|
load_posts.emit(MouseEvent::new("click").unwrap());
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<span class="tag-chip" onclick={remove}>
|
||||||
|
{ format!("#{}", t) }
|
||||||
|
<span class="tag-remove">{"×"}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}) }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if *error {
|
if *error {
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ pub struct NewsArticle {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
pub author: String,
|
pub author: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub category: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
pub struct RssFeed {
|
||||||
|
pub url: String,
|
||||||
|
pub category: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -30,14 +37,23 @@ struct StringArgs {
|
|||||||
key: String,
|
key: String,
|
||||||
}
|
}
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct UrlsArgs {
|
struct FeedsArgs {
|
||||||
urls: Vec<String>,
|
feeds: Vec<RssFeed>,
|
||||||
}
|
}
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct AiArgs {
|
struct AiArgs {
|
||||||
provider: String,
|
provider: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: &[&str] = &[
|
||||||
|
"Alle",
|
||||||
|
"Technik",
|
||||||
|
"Nachrichten",
|
||||||
|
"Wissenschaft",
|
||||||
|
"KI",
|
||||||
|
"Open Source",
|
||||||
|
];
|
||||||
|
|
||||||
#[function_component(News)]
|
#[function_component(News)]
|
||||||
pub fn news() -> Html {
|
pub fn news() -> Html {
|
||||||
let articles = use_state(|| None::<Vec<NewsArticle>>);
|
let articles = use_state(|| None::<Vec<NewsArticle>>);
|
||||||
@@ -45,20 +61,22 @@ pub fn news() -> Html {
|
|||||||
let show_config = use_state(|| false);
|
let show_config = use_state(|| false);
|
||||||
let current_mode = use_state(|| NewsMode::Ai);
|
let current_mode = use_state(|| NewsMode::Ai);
|
||||||
let ai_provider = use_state(|| "openrouter".to_string());
|
let ai_provider = use_state(|| "openrouter".to_string());
|
||||||
let rss_urls = use_state(Vec::<String>::new);
|
let rss_feeds = use_state(Vec::<RssFeed>::new);
|
||||||
|
let selected_category = use_state(|| "Alle".to_string());
|
||||||
|
|
||||||
let or_key_ref = use_node_ref();
|
let or_key_ref = use_node_ref();
|
||||||
let groq_key_ref = use_node_ref();
|
let groq_key_ref = use_node_ref();
|
||||||
let new_url_ref = use_node_ref();
|
let new_url_ref = use_node_ref();
|
||||||
|
let new_cat_ref = use_node_ref();
|
||||||
|
|
||||||
// Init: RSS laden
|
// Init: RSS Feeds laden
|
||||||
{
|
{
|
||||||
let rss_urls = rss_urls.clone();
|
let rss_feeds = rss_feeds.clone();
|
||||||
use_effect_with((), move |_| {
|
use_effect_with((), move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(res) = invoke("load_rss_config", JsValue::NULL).await {
|
if let Ok(res) = invoke("load_rss_config", JsValue::NULL).await {
|
||||||
if let Ok(urls) = serde_wasm_bindgen::from_value::<Vec<String>>(res) {
|
if let Ok(feeds) = serde_wasm_bindgen::from_value::<Vec<RssFeed>>(res) {
|
||||||
rss_urls.set(urls);
|
rss_feeds.set(feeds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -98,7 +116,7 @@ pub fn news() -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-Reload bei Switch
|
// Auto-Reload bei Mode/Provider-Switch
|
||||||
{
|
{
|
||||||
let load_news = load_news.clone();
|
let load_news = load_news.clone();
|
||||||
use_effect_with(
|
use_effect_with(
|
||||||
@@ -111,7 +129,7 @@ pub fn news() -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let save_settings = {
|
let save_settings = {
|
||||||
let rss_urls = rss_urls.clone();
|
let rss_feeds = rss_feeds.clone();
|
||||||
let or_ref = or_key_ref.clone();
|
let or_ref = or_key_ref.clone();
|
||||||
let groq_ref = groq_key_ref.clone();
|
let groq_ref = groq_key_ref.clone();
|
||||||
let show_config = show_config.clone();
|
let show_config = show_config.clone();
|
||||||
@@ -125,7 +143,7 @@ pub fn news() -> Html {
|
|||||||
.cast::<HtmlInputElement>()
|
.cast::<HtmlInputElement>()
|
||||||
.map(|i| i.value())
|
.map(|i| i.value())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let urls = (*rss_urls).clone();
|
let feeds = (*rss_feeds).clone();
|
||||||
let show_config = show_config.clone();
|
let show_config = show_config.clone();
|
||||||
let load_news = load_news.clone();
|
let load_news = load_news.clone();
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
@@ -141,7 +159,7 @@ pub fn news() -> Html {
|
|||||||
.await;
|
.await;
|
||||||
let _ = invoke(
|
let _ = invoke(
|
||||||
"save_rss_urls",
|
"save_rss_urls",
|
||||||
serde_wasm_bindgen::to_value(&UrlsArgs { urls }).unwrap(),
|
serde_wasm_bindgen::to_value(&FeedsArgs { feeds }).unwrap(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
show_config.set(false);
|
show_config.set(false);
|
||||||
@@ -150,6 +168,14 @@ pub fn news() -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gefilterte Artikel für die Anzeige
|
||||||
|
let filtered_articles: Vec<NewsArticle> = (*articles)
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| *selected_category == "Alle" || a.category == *selected_category)
|
||||||
|
.collect();
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="feed-container">
|
<div class="feed-container">
|
||||||
<div class="feed-header">
|
<div class="feed-header">
|
||||||
@@ -178,6 +204,24 @@ pub fn news() -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// ── Kategorie-Filterleiste ──────────────────────────────────────
|
||||||
|
if *current_mode == NewsMode::Rss {
|
||||||
|
<div class="category-bar">
|
||||||
|
{ for CATEGORIES.iter().map(|&cat| {
|
||||||
|
let selected_category = selected_category.clone();
|
||||||
|
let is_active = *selected_category == cat;
|
||||||
|
html! {
|
||||||
|
<button
|
||||||
|
class={if is_active { "cat-btn active" } else { "cat-btn" }}
|
||||||
|
onclick={Callback::from(move |_| selected_category.set(cat.to_string()))}>
|
||||||
|
{ cat }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}) }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Einstellungen ───────────────────────────────────────────────
|
||||||
if *show_config {
|
if *show_config {
|
||||||
<div class="config-panel">
|
<div class="config-panel">
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
@@ -190,19 +234,51 @@ pub fn news() -> Html {
|
|||||||
</div>
|
</div>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<label>{"RSS FEEDS"}</label>
|
<label>{"RSS FEEDS"}</label>
|
||||||
<div class="rss-input-row">
|
|
||||||
<input ref={new_url_ref.clone()} type="text" class="config-input" placeholder="URL..." />
|
// Bestehende Feeds auflisten
|
||||||
|
<div class="rss-list-scroll">
|
||||||
|
{ for (*rss_feeds).iter().enumerate().map(|(i, feed)| {
|
||||||
|
let rss_feeds = rss_feeds.clone();
|
||||||
|
let url_display = feed.url.clone();
|
||||||
|
let cat_display = feed.category.clone();
|
||||||
|
let on_delete = Callback::from(move |_| {
|
||||||
|
let mut list = (*rss_feeds).clone();
|
||||||
|
list.remove(i);
|
||||||
|
rss_feeds.set(list);
|
||||||
|
});
|
||||||
|
html! {
|
||||||
|
<div class="rss-url-entry" key={url_display.clone()}>
|
||||||
|
<span class="url-text">{ &url_display }</span>
|
||||||
|
<span class="cat-badge">{ &cat_display }</span>
|
||||||
|
<button class="delete-btn" onclick={on_delete}>{"🗑️"}</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}) }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Neue URL + Kategorie hinzufügen
|
||||||
|
<div class="rss-add-row">
|
||||||
|
<input ref={new_url_ref.clone()} type="text" class="config-input" placeholder="https://..." />
|
||||||
|
<select ref={new_cat_ref.clone()} class="cat-select">
|
||||||
|
{ for CATEGORIES.iter().skip(1).map(|&cat| html! {
|
||||||
|
<option value={cat}>{ cat }</option>
|
||||||
|
}) }
|
||||||
|
</select>
|
||||||
<button class="add-btn" onclick={
|
<button class="add-btn" onclick={
|
||||||
let rss_urls = rss_urls.clone();
|
let rss_feeds = rss_feeds.clone();
|
||||||
let new_url_ref = new_url_ref.clone();
|
let url_ref = new_url_ref.clone();
|
||||||
|
let cat_ref = new_cat_ref.clone();
|
||||||
move |_| {
|
move |_| {
|
||||||
if let Some(input) = new_url_ref.cast::<HtmlInputElement>() {
|
let url_input = url_ref.cast::<HtmlInputElement>();
|
||||||
let val = input.value();
|
let cat_input = cat_ref.cast::<HtmlInputElement>();
|
||||||
if !val.trim().is_empty() {
|
if let (Some(u), Some(c)) = (url_input, cat_input) {
|
||||||
let mut list = (*rss_urls).clone();
|
let url = u.value();
|
||||||
list.push(val);
|
let category = c.value();
|
||||||
rss_urls.set(list);
|
if !url.trim().is_empty() {
|
||||||
input.set_value("");
|
let mut list = (*rss_feeds).clone();
|
||||||
|
list.push(RssFeed { url, category });
|
||||||
|
rss_feeds.set(list);
|
||||||
|
u.set_value("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,13 +289,16 @@ pub fn news() -> Html {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Artikel-Liste ───────────────────────────────────────────────
|
||||||
<div class="posts-list">
|
<div class="posts-list">
|
||||||
{ if let Some(msg) = (*error_msg).clone() {
|
{ if let Some(msg) = (*error_msg).clone() {
|
||||||
html! { <div class="error-box">{ format!("⚠️ Error: {}", msg) }</div> }
|
html! { <div class="error-box">{ format!("⚠️ Error: {}", msg) }</div> }
|
||||||
} else if let Some(list) = (*articles).clone() {
|
} else if (*articles).is_none() {
|
||||||
html! { for list.iter().map(|a| html! { <NewsCard article={a.clone()} /> }) }
|
|
||||||
} else {
|
|
||||||
html! { <div class="loading-spinner">{"Lade Nachrichten..."}</div> }
|
html! { <div class="loading-spinner">{"Lade Nachrichten..."}</div> }
|
||||||
|
} else if filtered_articles.is_empty() {
|
||||||
|
html! { <div class="empty-msg">{"Keine Artikel in dieser Kategorie."}</div> }
|
||||||
|
} else {
|
||||||
|
html! { for filtered_articles.iter().map(|a| html! { <NewsCard article={a.clone()} /> }) }
|
||||||
} }
|
} }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,8 +325,11 @@ pub fn news_card(props: &NewsCardProps) -> Html {
|
|||||||
<div class="post-card">
|
<div class="post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<span class="post-author">{ &a.author }</span>
|
<span class="post-author">{ &a.author }</span>
|
||||||
|
<div class="post-meta">
|
||||||
|
<span class="post-category-badge">{ &a.category }</span>
|
||||||
<span class="post-date">{ &a.created_at }</span>
|
<span class="post-date">{ &a.created_at }</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="post-content markdown-body">
|
<div class="post-content markdown-body">
|
||||||
{ Html::from_html_unchecked(AttrValue::from(markdown_html)) }
|
{ Html::from_html_unchecked(AttrValue::from(markdown_html)) }
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
529
styles.css
529
styles.css
@@ -1,521 +1,10 @@
|
|||||||
@import url("assets/home.css");
|
@import url("assets/variables.css");
|
||||||
@import url("assets/news.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");
|
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Layout Adjustment für Provider Switch --- */
|
|
||||||
.header-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|||||||
20
zapstore.yaml
Normal file
20
zapstore.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: "Marstemedia"
|
||||||
|
package: "de.malxte.marstemedia" # Deine Android Package ID
|
||||||
|
description: "A modern, fast, and secure application to stay updated with the latest news from different worlds — from decentralized social networks to AI-generated insights. "
|
||||||
|
author: "Bytemalte"
|
||||||
|
|
||||||
|
# Hier definierst du, woher zsp die APKs beziehen soll
|
||||||
|
release_source:
|
||||||
|
type: "gitea" # Oder "web", falls Gitea Probleme macht
|
||||||
|
url: "https://gitea.malxte.de/Bytemalte/marstemedia"
|
||||||
|
|
||||||
|
# Falls du schon Relays im Kopf hast:
|
||||||
|
relays:
|
||||||
|
- "wss://relay.zapstore.dev"
|
||||||
|
- "wss://relay.malxte.de"
|
||||||
|
- "wss://relay.primal.net"
|
||||||
|
- "wss://relay.damus.io"
|
||||||
|
- "wss://nostr.mom"
|
||||||
|
- "wss://snort.social"
|
||||||
|
|
||||||
|
icon_url: "https://gitea.malxte.de/Bytemalte/marstemedia/raw/branch/main/public/logo.png"
|
||||||
Reference in New Issue
Block a user