Fix Shield popover airspace and add Phase 3 library board core
Browse files- README.md +0 -24
- src-tauri/Cargo.toml +4 -0
- src-tauri/resources/scripts/hover_overlay.js +61 -0
- src-tauri/src/adblock/scripts.rs +3 -1
- src-tauri/src/adblock/updater.rs +3 -2
- src-tauri/src/board.rs +64 -0
- src-tauri/src/lib.rs +13 -0
- src-tauri/src/library.rs +100 -0
- src/App.tsx +4 -2
- src/components/BoardPanel.tsx +103 -0
- src/components/LibraryPanel.tsx +96 -0
- src/components/browser/BrowserChrome.tsx +32 -23
- src/styles/app.css +50 -0
README.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
| 1 |
-
---
|
| 2 |
-
tags:
|
| 3 |
-
- ml-intern
|
| 4 |
-
---
|
| 5 |
# MUSE Alpha
|
| 6 |
|
| 7 |
Creative browser prototype: Tauri v2 + Rust + SolidJS.
|
|
@@ -36,23 +32,3 @@ Frontend-only build:
|
|
| 36 |
```bash
|
| 37 |
pnpm build
|
| 38 |
```
|
| 39 |
-
|
| 40 |
-
<!-- ml-intern-provenance -->
|
| 41 |
-
## Generated by ML Intern
|
| 42 |
-
|
| 43 |
-
This model repository was generated by [ML Intern](https://github.com/huggingface/ml-intern), an agent for machine learning research and development on the Hugging Face Hub.
|
| 44 |
-
|
| 45 |
-
- Try ML Intern: https://smolagents-ml-intern.hf.space
|
| 46 |
-
- Source code: https://github.com/huggingface/ml-intern
|
| 47 |
-
|
| 48 |
-
## Usage
|
| 49 |
-
|
| 50 |
-
```python
|
| 51 |
-
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 52 |
-
|
| 53 |
-
model_id = 'asdf98/musealpha'
|
| 54 |
-
tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 55 |
-
model = AutoModelForCausalLM.from_pretrained(model_id)
|
| 56 |
-
```
|
| 57 |
-
|
| 58 |
-
For non-causal architectures, replace `AutoModelForCausalLM` with the appropriate `AutoModel` class.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# MUSE Alpha
|
| 2 |
|
| 3 |
Creative browser prototype: Tauri v2 + Rust + SolidJS.
|
|
|
|
| 32 |
```bash
|
| 33 |
pnpm build
|
| 34 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src-tauri/Cargo.toml
CHANGED
|
@@ -36,6 +36,10 @@ tokio = { version = "1", features = ["time", "sync"] }
|
|
| 36 |
|
| 37 |
# Utilities
|
| 38 |
once_cell = "1"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
chrono = { version = "0.4", features = ["serde"] }
|
| 40 |
|
| 41 |
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
|
|
|
| 36 |
|
| 37 |
# Utilities
|
| 38 |
once_cell = "1"
|
| 39 |
+
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp"] }
|
| 40 |
+
blake3 = "1"
|
| 41 |
+
base64 = "0.22"
|
| 42 |
+
uuid = { version = "1", features = ["v4", "serde"] }
|
| 43 |
chrono = { version = "0.4", features = ["serde"] }
|
| 44 |
|
| 45 |
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
src-tauri/resources/scripts/hover_overlay.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function() {
|
| 2 |
+
'use strict';
|
| 3 |
+
let overlay = null;
|
| 4 |
+
let currentImg = null;
|
| 5 |
+
let timer = null;
|
| 6 |
+
const MIN = 90;
|
| 7 |
+
|
| 8 |
+
function invoke(cmd, args) {
|
| 9 |
+
try {
|
| 10 |
+
if (window.__TAURI_INTERNALS__?.invoke) return window.__TAURI_INTERNALS__.invoke(cmd, args);
|
| 11 |
+
if (window.__TAURI__?.core?.invoke) return window.__TAURI__.core.invoke(cmd, args);
|
| 12 |
+
} catch (_) {}
|
| 13 |
+
console.warn('[Muse] Tauri IPC unavailable in this page');
|
| 14 |
+
return Promise.reject(new Error('Muse IPC unavailable'));
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function button(label) {
|
| 18 |
+
const b = document.createElement('button');
|
| 19 |
+
b.textContent = label;
|
| 20 |
+
b.style.cssText = 'height:26px;border:1px solid rgba(255,255,255,.25);border-radius:8px;background:rgba(20,18,14,.86);color:white;padding:0 10px;font:12px system-ui;cursor:pointer';
|
| 21 |
+
return b;
|
| 22 |
+
}
|
| 23 |
+
function ensureOverlay() {
|
| 24 |
+
if (overlay) return overlay;
|
| 25 |
+
overlay = document.createElement('div');
|
| 26 |
+
overlay.id = '__muse_image_overlay';
|
| 27 |
+
overlay.style.cssText = 'position:fixed;z-index:2147483647;display:none;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;background:rgba(10,8,6,.78);backdrop-filter:blur(12px);box-shadow:0 8px 30px rgba(0,0,0,.35);pointer-events:auto';
|
| 28 |
+
const meta = document.createElement('span'); meta.id='__muse_meta'; meta.style.cssText='color:white;font:12px ui-monospace,monospace;opacity:.9';
|
| 29 |
+
const save = button('+ Library');
|
| 30 |
+
save.onclick = async () => {
|
| 31 |
+
if (!currentImg) return;
|
| 32 |
+
save.textContent = 'Saving…';
|
| 33 |
+
try { await invoke('library_add_item', { url: currentImg.src, sourceUrl: location.href, title: document.title || currentImg.alt || 'Reference' }); save.textContent = '✓ Saved'; }
|
| 34 |
+
catch { save.textContent = 'Save failed'; }
|
| 35 |
+
setTimeout(()=>save.textContent='+ Library', 1500);
|
| 36 |
+
};
|
| 37 |
+
const palette = button('🎨');
|
| 38 |
+
palette.onclick = async () => { if (currentImg) save.click(); };
|
| 39 |
+
overlay.append(meta, save, palette);
|
| 40 |
+
document.documentElement.appendChild(overlay);
|
| 41 |
+
return overlay;
|
| 42 |
+
}
|
| 43 |
+
function show(img) {
|
| 44 |
+
currentImg = img;
|
| 45 |
+
const o = ensureOverlay();
|
| 46 |
+
const r = img.getBoundingClientRect();
|
| 47 |
+
o.querySelector('#__muse_meta').textContent = `${img.naturalWidth || Math.round(r.width)}×${img.naturalHeight || Math.round(r.height)}`;
|
| 48 |
+
o.style.left = `${Math.min(r.left + 10, window.innerWidth - 260)}px`;
|
| 49 |
+
o.style.top = `${Math.max(10, r.top + 10)}px`;
|
| 50 |
+
o.style.display = 'flex';
|
| 51 |
+
}
|
| 52 |
+
function hide() { if (overlay) overlay.style.display = 'none'; currentImg = null; }
|
| 53 |
+
function eligible(el) { return el && el.tagName === 'IMG' && el.src && el.getBoundingClientRect().width >= MIN && el.getBoundingClientRect().height >= MIN; }
|
| 54 |
+
document.addEventListener('mousemove', e => {
|
| 55 |
+
clearTimeout(timer);
|
| 56 |
+
const target = e.target;
|
| 57 |
+
if (!eligible(target)) { if (!overlay?.contains(target)) hide(); return; }
|
| 58 |
+
timer = setTimeout(() => show(target), 350);
|
| 59 |
+
}, true);
|
| 60 |
+
document.addEventListener('scroll', hide, true);
|
| 61 |
+
})();
|
src-tauri/src/adblock/scripts.rs
CHANGED
|
@@ -5,6 +5,7 @@ pub fn build_init_script(blocked_domains_json: &str) -> String {
|
|
| 5 |
let cookie_consent = include_str!("../../resources/scripts/cookie_consent.js");
|
| 6 |
let webrtc_protect = include_str!("../../resources/scripts/webrtc_protect.js");
|
| 7 |
let canvas_noise = include_str!("../../resources/scripts/canvas_noise.js");
|
|
|
|
| 8 |
|
| 9 |
format!(
|
| 10 |
r#"(function() {{
|
|
@@ -14,7 +15,8 @@ window.__MUSE_BLOCKED_DOMAINS__ = {blocked_domains_json};
|
|
| 14 |
{adblock_layer1}
|
| 15 |
{cookie_consent}
|
| 16 |
{webrtc_protect}
|
| 17 |
-
{canvas_noise}
|
|
|
|
| 18 |
)
|
| 19 |
}
|
| 20 |
|
|
|
|
| 5 |
let cookie_consent = include_str!("../../resources/scripts/cookie_consent.js");
|
| 6 |
let webrtc_protect = include_str!("../../resources/scripts/webrtc_protect.js");
|
| 7 |
let canvas_noise = include_str!("../../resources/scripts/canvas_noise.js");
|
| 8 |
+
let hover_overlay = include_str!("../../resources/scripts/hover_overlay.js");
|
| 9 |
|
| 10 |
format!(
|
| 11 |
r#"(function() {{
|
|
|
|
| 15 |
{adblock_layer1}
|
| 16 |
{cookie_consent}
|
| 17 |
{webrtc_protect}
|
| 18 |
+
{canvas_noise}
|
| 19 |
+
{hover_overlay}"#
|
| 20 |
)
|
| 21 |
}
|
| 22 |
|
src-tauri/src/adblock/updater.rs
CHANGED
|
@@ -41,8 +41,9 @@ pub async fn update_now(app: &AppHandle) -> Result<(), String> {
|
|
| 41 |
Ok(resp) => {
|
| 42 |
if let Ok(text) = resp.text().await {
|
| 43 |
let _meta = filter_set.add_filter_list(&text, ParseOptions::default());
|
| 44 |
-
|
| 45 |
-
|
|
|
|
| 46 |
}
|
| 47 |
}
|
| 48 |
Err(e) => {
|
|
|
|
| 41 |
Ok(resp) => {
|
| 42 |
if let Ok(text) = resp.text().await {
|
| 43 |
let _meta = filter_set.add_filter_list(&text, ParseOptions::default());
|
| 44 |
+
let rule_count = text.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('!')).count();
|
| 45 |
+
total_rules += rule_count;
|
| 46 |
+
println!("[muse-shield] Updated {name}: {rule_count} candidate rules");
|
| 47 |
}
|
| 48 |
}
|
| 49 |
Err(e) => {
|
src-tauri/src/board.rs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use serde::{Deserialize, Serialize};
|
| 2 |
+
use std::sync::Mutex;
|
| 3 |
+
use tauri::{AppHandle, Manager};
|
| 4 |
+
use uuid::Uuid;
|
| 5 |
+
|
| 6 |
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 7 |
+
pub struct BoardItem {
|
| 8 |
+
pub id: String,
|
| 9 |
+
pub kind: String,
|
| 10 |
+
pub library_id: Option<String>,
|
| 11 |
+
pub data_url: Option<String>,
|
| 12 |
+
pub text: Option<String>,
|
| 13 |
+
pub colors: Vec<String>,
|
| 14 |
+
pub x: f64,
|
| 15 |
+
pub y: f64,
|
| 16 |
+
pub w: f64,
|
| 17 |
+
pub h: f64,
|
| 18 |
+
pub z: i64,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
#[derive(Default)]
|
| 22 |
+
pub struct BoardState {
|
| 23 |
+
pub items: Mutex<Vec<BoardItem>>,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#[tauri::command]
|
| 27 |
+
pub fn board_items(app: AppHandle) -> Result<Vec<BoardItem>, String> {
|
| 28 |
+
Ok(app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.clone())
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#[tauri::command]
|
| 32 |
+
pub fn board_add_image(app: AppHandle, library_id: String, data_url: String, x: f64, y: f64, w: f64, h: f64) -> Result<BoardItem, String> {
|
| 33 |
+
let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "image".into(), library_id: Some(library_id), data_url: Some(data_url), text: None, colors: vec![], x, y, w, h, z: chrono::Utc::now().timestamp_millis() };
|
| 34 |
+
app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.push(item.clone());
|
| 35 |
+
Ok(item)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
#[tauri::command]
|
| 39 |
+
pub fn board_add_note(app: AppHandle, text: String, x: f64, y: f64) -> Result<BoardItem, String> {
|
| 40 |
+
let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "note".into(), library_id: None, data_url: None, text: Some(text), colors: vec![], x, y, w: 220.0, h: 140.0, z: chrono::Utc::now().timestamp_millis() };
|
| 41 |
+
app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.push(item.clone());
|
| 42 |
+
Ok(item)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
#[tauri::command]
|
| 46 |
+
pub fn board_add_palette(app: AppHandle, colors: Vec<String>, x: f64, y: f64) -> Result<BoardItem, String> {
|
| 47 |
+
let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "palette".into(), library_id: None, data_url: None, text: None, colors, x, y, w: 260.0, h: 90.0, z: chrono::Utc::now().timestamp_millis() };
|
| 48 |
+
app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.push(item.clone());
|
| 49 |
+
Ok(item)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
#[tauri::command]
|
| 53 |
+
pub fn board_update_item(app: AppHandle, item: BoardItem) -> Result<(), String> {
|
| 54 |
+
let state = app.state::<BoardState>();
|
| 55 |
+
let mut items = state.items.lock().map_err(|_| "board lock poisoned".to_string())?;
|
| 56 |
+
if let Some(slot) = items.iter_mut().find(|i| i.id == item.id) { *slot = item; }
|
| 57 |
+
Ok(())
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
#[tauri::command]
|
| 61 |
+
pub fn board_delete_item(app: AppHandle, id: String) -> Result<(), String> {
|
| 62 |
+
app.state::<BoardState>().items.lock().map_err(|_| "board lock poisoned".to_string())?.retain(|i| i.id != id);
|
| 63 |
+
Ok(())
|
| 64 |
+
}
|
src-tauri/src/lib.rs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
mod adblock;
|
| 2 |
mod browser;
|
|
|
|
|
|
|
| 3 |
mod settings;
|
| 4 |
mod state;
|
| 5 |
|
|
@@ -19,6 +21,8 @@ pub fn run() {
|
|
| 19 |
)
|
| 20 |
.manage(state::AppState::default())
|
| 21 |
.manage(adblock::engine::AdBlockState::new())
|
|
|
|
|
|
|
| 22 |
.invoke_handler(tauri::generate_handler![
|
| 23 |
settings::phase0_status,
|
| 24 |
browser::browser_init,
|
|
@@ -39,6 +43,15 @@ pub fn run() {
|
|
| 39 |
adblock::commands::shield_toggle_domain,
|
| 40 |
adblock::commands::shield_is_allowed,
|
| 41 |
adblock::commands::shield_update_lists,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
])
|
| 43 |
.setup(|app| {
|
| 44 |
#[cfg(desktop)]
|
|
|
|
| 1 |
mod adblock;
|
| 2 |
mod browser;
|
| 3 |
+
mod library;
|
| 4 |
+
mod board;
|
| 5 |
mod settings;
|
| 6 |
mod state;
|
| 7 |
|
|
|
|
| 21 |
)
|
| 22 |
.manage(state::AppState::default())
|
| 23 |
.manage(adblock::engine::AdBlockState::new())
|
| 24 |
+
.manage(library::LibraryState::default())
|
| 25 |
+
.manage(board::BoardState::default())
|
| 26 |
.invoke_handler(tauri::generate_handler![
|
| 27 |
settings::phase0_status,
|
| 28 |
browser::browser_init,
|
|
|
|
| 43 |
adblock::commands::shield_toggle_domain,
|
| 44 |
adblock::commands::shield_is_allowed,
|
| 45 |
adblock::commands::shield_update_lists,
|
| 46 |
+
library::library_add_item,
|
| 47 |
+
library::library_items,
|
| 48 |
+
library::library_search,
|
| 49 |
+
board::board_items,
|
| 50 |
+
board::board_add_image,
|
| 51 |
+
board::board_add_note,
|
| 52 |
+
board::board_add_palette,
|
| 53 |
+
board::board_update_item,
|
| 54 |
+
board::board_delete_item,
|
| 55 |
])
|
| 56 |
.setup(|app| {
|
| 57 |
#[cfg(desktop)]
|
src-tauri/src/library.rs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use base64::{engine::general_purpose, Engine as _};
|
| 2 |
+
use image::{GenericImageView, Pixel};
|
| 3 |
+
use serde::{Deserialize, Serialize};
|
| 4 |
+
use std::sync::Mutex;
|
| 5 |
+
use tauri::{AppHandle, Manager};
|
| 6 |
+
use uuid::Uuid;
|
| 7 |
+
|
| 8 |
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 9 |
+
pub struct LibraryItem {
|
| 10 |
+
pub id: String,
|
| 11 |
+
pub url: String,
|
| 12 |
+
pub source_url: String,
|
| 13 |
+
pub title: String,
|
| 14 |
+
pub data_url: String,
|
| 15 |
+
pub hash: String,
|
| 16 |
+
pub width: u32,
|
| 17 |
+
pub height: u32,
|
| 18 |
+
pub colors: Vec<String>,
|
| 19 |
+
pub tags: Vec<String>,
|
| 20 |
+
pub created_at: i64,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
#[derive(Default)]
|
| 24 |
+
pub struct LibraryState {
|
| 25 |
+
pub items: Mutex<Vec<LibraryItem>>,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
#[tauri::command]
|
| 29 |
+
pub async fn library_add_item(app: AppHandle, url: String, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> {
|
| 30 |
+
let bytes = reqwest::get(&url).await.map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?;
|
| 31 |
+
let hash = blake3::hash(&bytes).to_hex().to_string();
|
| 32 |
+
let img = image::load_from_memory(&bytes).map_err(|e| format!("unsupported image: {e}"))?;
|
| 33 |
+
let (width, height) = img.dimensions();
|
| 34 |
+
let colors = extract_palette(&img, 6);
|
| 35 |
+
let mime = if url.to_lowercase().ends_with(".png") { "image/png" } else if url.to_lowercase().ends_with(".webp") { "image/webp" } else if url.to_lowercase().ends_with(".gif") { "image/gif" } else { "image/jpeg" };
|
| 36 |
+
let data_url = format!("data:{mime};base64,{}", general_purpose::STANDARD.encode(&bytes));
|
| 37 |
+
let item = LibraryItem {
|
| 38 |
+
id: Uuid::new_v4().to_string(),
|
| 39 |
+
url,
|
| 40 |
+
source_url: source_url.unwrap_or_default(),
|
| 41 |
+
title: title.unwrap_or_else(|| "Untitled reference".to_string()),
|
| 42 |
+
data_url,
|
| 43 |
+
hash,
|
| 44 |
+
width,
|
| 45 |
+
height,
|
| 46 |
+
colors,
|
| 47 |
+
tags: vec![],
|
| 48 |
+
created_at: chrono::Utc::now().timestamp(),
|
| 49 |
+
};
|
| 50 |
+
let state = app.state::<LibraryState>();
|
| 51 |
+
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 52 |
+
if let Some(existing) = items.iter().find(|i| i.hash == item.hash) {
|
| 53 |
+
return Ok(existing.clone());
|
| 54 |
+
}
|
| 55 |
+
items.push(item.clone());
|
| 56 |
+
Ok(item)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
#[tauri::command]
|
| 60 |
+
pub fn library_items(app: AppHandle) -> Result<Vec<LibraryItem>, String> {
|
| 61 |
+
let state = app.state::<LibraryState>();
|
| 62 |
+
Ok(state.items.lock().map_err(|_| "library lock poisoned".to_string())?.clone())
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
#[tauri::command]
|
| 66 |
+
pub fn library_search(app: AppHandle, query: String) -> Result<Vec<LibraryItem>, String> {
|
| 67 |
+
let q = query.to_lowercase();
|
| 68 |
+
let state = app.state::<LibraryState>();
|
| 69 |
+
let items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 70 |
+
Ok(items.iter().filter(|item| {
|
| 71 |
+
item.title.to_lowercase().contains(&q)
|
| 72 |
+
|| item.url.to_lowercase().contains(&q)
|
| 73 |
+
|| item.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
| 74 |
+
}).cloned().collect())
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec<String> {
|
| 78 |
+
let thumb = img.thumbnail(96, 96).to_rgb8();
|
| 79 |
+
let mut buckets: Vec<Vec<[u8;3]>> = vec![thumb.pixels().map(|p| [p[0], p[1], p[2]]).collect()];
|
| 80 |
+
while buckets.len() < count {
|
| 81 |
+
let Some((idx, axis)) = buckets.iter().enumerate().filter(|(_, b)| b.len() > 1).map(|(i,b)| {
|
| 82 |
+
let ranges = (0..3).map(|c| {
|
| 83 |
+
let min = b.iter().map(|p| p[c]).min().unwrap_or(0);
|
| 84 |
+
let max = b.iter().map(|p| p[c]).max().unwrap_or(0);
|
| 85 |
+
max - min
|
| 86 |
+
}).collect::<Vec<_>>();
|
| 87 |
+
let axis = ranges.iter().enumerate().max_by_key(|(_, r)| *r).map(|(a,_)| a).unwrap_or(0);
|
| 88 |
+
(i, axis)
|
| 89 |
+
}).next() else { break; };
|
| 90 |
+
let mut b = buckets.remove(idx);
|
| 91 |
+
b.sort_by_key(|p| p[axis]);
|
| 92 |
+
let right = b.split_off(b.len()/2);
|
| 93 |
+
buckets.push(b); buckets.push(right);
|
| 94 |
+
}
|
| 95 |
+
buckets.into_iter().filter(|b| !b.is_empty()).map(|b| {
|
| 96 |
+
let n=b.len() as u32;
|
| 97 |
+
let (r,g,bl)=b.iter().fold((0u32,0u32,0u32), |a,p| (a.0+p[0] as u32,a.1+p[1] as u32,a.2+p[2] as u32));
|
| 98 |
+
format!("#{:02X}{:02X}{:02X}", r/n, g/n, bl/n)
|
| 99 |
+
}).collect()
|
| 100 |
+
}
|
src/App.tsx
CHANGED
|
@@ -5,6 +5,8 @@ import Titlebar from "./components/Titlebar";
|
|
| 5 |
import HomePage from "./components/HomePage";
|
| 6 |
import Onboarding from "./components/Onboarding";
|
| 7 |
import PlaceholderPanel from "./components/PlaceholderPanel";
|
|
|
|
|
|
|
| 8 |
import ThemeSwitcher from "./components/ThemeSwitcher";
|
| 9 |
import BrowserChrome from "./components/browser/BrowserChrome";
|
| 10 |
|
|
@@ -43,10 +45,10 @@ function App() {
|
|
| 43 |
<main classList={{ "content-shell": true, visible: settings().activeSection !== "home" }}>
|
| 44 |
<Switch>
|
| 45 |
<Match when={settings().activeSection === "library"}>
|
| 46 |
-
<
|
| 47 |
</Match>
|
| 48 |
<Match when={settings().activeSection === "board"}>
|
| 49 |
-
<
|
| 50 |
</Match>
|
| 51 |
<Match when={settings().activeSection === "settings"}>
|
| 52 |
<section class="panel settings-panel">
|
|
|
|
| 5 |
import HomePage from "./components/HomePage";
|
| 6 |
import Onboarding from "./components/Onboarding";
|
| 7 |
import PlaceholderPanel from "./components/PlaceholderPanel";
|
| 8 |
+
import LibraryPanel from "./components/LibraryPanel";
|
| 9 |
+
import BoardPanel from "./components/BoardPanel";
|
| 10 |
import ThemeSwitcher from "./components/ThemeSwitcher";
|
| 11 |
import BrowserChrome from "./components/browser/BrowserChrome";
|
| 12 |
|
|
|
|
| 45 |
<main classList={{ "content-shell": true, visible: settings().activeSection !== "home" }}>
|
| 46 |
<Switch>
|
| 47 |
<Match when={settings().activeSection === "library"}>
|
| 48 |
+
<LibraryPanel />
|
| 49 |
</Match>
|
| 50 |
<Match when={settings().activeSection === "board"}>
|
| 51 |
+
<BoardPanel />
|
| 52 |
</Match>
|
| 53 |
<Match when={settings().activeSection === "settings"}>
|
| 54 |
<section class="panel settings-panel">
|
src/components/BoardPanel.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createSignal, For, onMount } from "solid-js";
|
| 2 |
+
import { invoke } from "@tauri-apps/api/core";
|
| 3 |
+
import Plus from "lucide-solid/icons/plus";
|
| 4 |
+
import StickyNote from "lucide-solid/icons/sticky-note";
|
| 5 |
+
import Palette from "lucide-solid/icons/palette";
|
| 6 |
+
import Trash2 from "lucide-solid/icons/trash-2";
|
| 7 |
+
import type { LibraryItem } from "./LibraryPanel";
|
| 8 |
+
|
| 9 |
+
interface BoardItem {
|
| 10 |
+
id: string;
|
| 11 |
+
kind: string;
|
| 12 |
+
library_id?: string | null;
|
| 13 |
+
data_url?: string | null;
|
| 14 |
+
text?: string | null;
|
| 15 |
+
colors: string[];
|
| 16 |
+
x: number; y: number; w: number; h: number; z: number;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function BoardPanel() {
|
| 20 |
+
const [items, setItems] = createSignal<BoardItem[]>([]);
|
| 21 |
+
const [viewport, setViewport] = createSignal({ x: 40, y: 40, scale: 1 });
|
| 22 |
+
let boardEl!: HTMLDivElement;
|
| 23 |
+
|
| 24 |
+
const load = async () => setItems(await invoke<BoardItem[]>("board_items"));
|
| 25 |
+
onMount(load);
|
| 26 |
+
|
| 27 |
+
const screenToWorld = (clientX: number, clientY: number) => {
|
| 28 |
+
const r = boardEl.getBoundingClientRect();
|
| 29 |
+
const v = viewport();
|
| 30 |
+
return { x: (clientX - r.left - v.x) / v.scale, y: (clientY - r.top - v.y) / v.scale };
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const addNote = async () => { await invoke("board_add_note", { text: "Observation note…", x: 120, y: 120 }); await load(); };
|
| 34 |
+
const addPalette = async () => { await invoke("board_add_palette", { colors: ["#C49A3C", "#100E0B", "#F0EDE6", "#4CAF6E"], x: 180, y: 180 }); await load(); };
|
| 35 |
+
const del = async (id: string) => { await invoke("board_delete_item", { id }); await load(); };
|
| 36 |
+
|
| 37 |
+
const update = async (item: BoardItem) => { await invoke("board_update_item", { item }); };
|
| 38 |
+
|
| 39 |
+
const startDrag = (e: PointerEvent, item: BoardItem) => {
|
| 40 |
+
e.stopPropagation();
|
| 41 |
+
const start = screenToWorld(e.clientX, e.clientY);
|
| 42 |
+
const ox = item.x, oy = item.y;
|
| 43 |
+
const move = (ev: PointerEvent) => {
|
| 44 |
+
const p = screenToWorld(ev.clientX, ev.clientY);
|
| 45 |
+
item.x = ox + p.x - start.x; item.y = oy + p.y - start.y;
|
| 46 |
+
setItems([...items()]);
|
| 47 |
+
};
|
| 48 |
+
const up = () => { window.removeEventListener("pointermove", move); void update(item); };
|
| 49 |
+
window.addEventListener("pointermove", move);
|
| 50 |
+
window.addEventListener("pointerup", up, { once: true });
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const startPan = (e: PointerEvent) => {
|
| 54 |
+
if ((e.target as HTMLElement).closest(".board-card")) return;
|
| 55 |
+
const startClient = { x: e.clientX, y: e.clientY };
|
| 56 |
+
const startView = viewport();
|
| 57 |
+
const move = (ev: PointerEvent) => setViewport({ ...startView, x: startView.x + ev.clientX - startClient.x, y: startView.y + ev.clientY - startClient.y });
|
| 58 |
+
const up = () => window.removeEventListener("pointermove", move);
|
| 59 |
+
window.addEventListener("pointermove", move);
|
| 60 |
+
window.addEventListener("pointerup", up, { once: true });
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const onWheel = (e: WheelEvent) => {
|
| 64 |
+
e.preventDefault();
|
| 65 |
+
const v = viewport();
|
| 66 |
+
const next = Math.min(3, Math.max(.25, v.scale * Math.exp(-e.deltaY * .001)));
|
| 67 |
+
setViewport({ ...v, scale: next });
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const drop = async (e: DragEvent) => {
|
| 71 |
+
e.preventDefault();
|
| 72 |
+
const raw = e.dataTransfer?.getData("application/x-muse-library-item");
|
| 73 |
+
if (!raw) return;
|
| 74 |
+
const item = JSON.parse(raw) as LibraryItem;
|
| 75 |
+
const p = screenToWorld(e.clientX, e.clientY);
|
| 76 |
+
await invoke("board_add_image", { libraryId: item.id, dataUrl: item.data_url, x: p.x, y: p.y, w: Math.min(420, item.width / 2), h: Math.min(320, item.height / 2) });
|
| 77 |
+
await load();
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
<section class="board-module">
|
| 82 |
+
<header class="board-toolbar-panel">
|
| 83 |
+
<div><p class="eyebrow">Board</p><h1>Reference Board</h1></div>
|
| 84 |
+
<button class="secondary" onClick={() => void addNote()}><StickyNote size={16}/> Note</button>
|
| 85 |
+
<button class="secondary" onClick={() => void addPalette()}><Palette size={16}/> Palette</button>
|
| 86 |
+
<button class="secondary" onClick={() => setViewport({ x: 40, y: 40, scale: 1 })}>Reset view</button>
|
| 87 |
+
</header>
|
| 88 |
+
<div class="board-canvas" ref={boardEl} onPointerDown={startPan} onWheel={onWheel} onDragOver={(e) => e.preventDefault()} onDrop={(e) => void drop(e)}>
|
| 89 |
+
<div class="board-grid-bg" style={{ transform: `translate(${viewport().x}px, ${viewport().y}px) scale(${viewport().scale})` }} />
|
| 90 |
+
<div class="board-world" style={{ transform: `translate(${viewport().x}px, ${viewport().y}px) scale(${viewport().scale})`, "transform-origin": "0 0" }}>
|
| 91 |
+
<For each={items()}>{(item) => (
|
| 92 |
+
<article class={`board-card ${item.kind}`} style={{ left: `${item.x}px`, top: `${item.y}px`, width: `${item.w}px`, height: `${item.h}px`, "z-index": item.z }} onPointerDown={(e) => startDrag(e, item)}>
|
| 93 |
+
{item.kind === "image" && <img src={item.data_url || ""} draggable={false}/>}
|
| 94 |
+
{item.kind === "note" && <textarea value={item.text || ""} onInput={(e) => { item.text = e.currentTarget.value; void update(item); }} />}
|
| 95 |
+
{item.kind === "palette" && <div class="board-palette">{item.colors.map(c => <span class="palette-chip" style={{"background-color": c}} title={c} />)}</div>}
|
| 96 |
+
<button class="board-delete" onClick={(e) => { e.stopPropagation(); void del(item.id); }}><Trash2 size={14}/></button>
|
| 97 |
+
</article>
|
| 98 |
+
)}</For>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</section>
|
| 102 |
+
);
|
| 103 |
+
}
|
src/components/LibraryPanel.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createSignal, For, onMount, Show } from "solid-js";
|
| 2 |
+
import { invoke } from "@tauri-apps/api/core";
|
| 3 |
+
import Search from "lucide-solid/icons/search";
|
| 4 |
+
import Plus from "lucide-solid/icons/plus";
|
| 5 |
+
import ImageIcon from "lucide-solid/icons/image";
|
| 6 |
+
import Palette from "lucide-solid/icons/palette";
|
| 7 |
+
|
| 8 |
+
export interface LibraryItem {
|
| 9 |
+
id: string;
|
| 10 |
+
url: string;
|
| 11 |
+
source_url: string;
|
| 12 |
+
title: string;
|
| 13 |
+
data_url: string;
|
| 14 |
+
hash: string;
|
| 15 |
+
width: number;
|
| 16 |
+
height: number;
|
| 17 |
+
colors: string[];
|
| 18 |
+
tags: string[];
|
| 19 |
+
created_at: number;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export default function LibraryPanel() {
|
| 23 |
+
const [items, setItems] = createSignal<LibraryItem[]>([]);
|
| 24 |
+
const [selected, setSelected] = createSignal<LibraryItem | null>(null);
|
| 25 |
+
const [url, setUrl] = createSignal("");
|
| 26 |
+
const [query, setQuery] = createSignal("");
|
| 27 |
+
const [busy, setBusy] = createSignal(false);
|
| 28 |
+
|
| 29 |
+
const load = async () => setItems(await invoke<LibraryItem[]>("library_items"));
|
| 30 |
+
onMount(load);
|
| 31 |
+
|
| 32 |
+
const addUrl = async () => {
|
| 33 |
+
const value = url().trim();
|
| 34 |
+
if (!value) return;
|
| 35 |
+
setBusy(true);
|
| 36 |
+
try {
|
| 37 |
+
const item = await invoke<LibraryItem>("library_add_item", { url: value, sourceUrl: value, title: value.split('/').pop() || "Reference" });
|
| 38 |
+
setItems(await invoke<LibraryItem[]>("library_items"));
|
| 39 |
+
setSelected(item);
|
| 40 |
+
setUrl("");
|
| 41 |
+
} finally { setBusy(false); }
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const search = async (q: string) => {
|
| 45 |
+
setQuery(q);
|
| 46 |
+
setItems(q.trim() ? await invoke<LibraryItem[]>("library_search", { query: q }) : await invoke<LibraryItem[]>("library_items"));
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const badgeClass = (item: LibraryItem) => item.width >= 1800 || item.height >= 1800 ? "good" : item.width >= 900 || item.height >= 900 ? "warn" : "bad";
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<section class="library-module">
|
| 53 |
+
<header class="module-header">
|
| 54 |
+
<div>
|
| 55 |
+
<p class="eyebrow">Library</p>
|
| 56 |
+
<h1>Reference Library</h1>
|
| 57 |
+
<p class="muted">Local-first visual archive with colors, dimensions, source URL and board drag-out.</p>
|
| 58 |
+
</div>
|
| 59 |
+
<div class="library-addbar">
|
| 60 |
+
<input value={url()} onInput={(e) => setUrl(e.currentTarget.value)} placeholder="Paste image URL to save…" />
|
| 61 |
+
<button class="primary small" disabled={busy()} onClick={() => void addUrl()}><Plus size={16}/> Add</button>
|
| 62 |
+
</div>
|
| 63 |
+
</header>
|
| 64 |
+
|
| 65 |
+
<div class="library-layout">
|
| 66 |
+
<main class="library-main">
|
| 67 |
+
<div class="library-search"><Search size={16}/><input value={query()} onInput={(e) => void search(e.currentTarget.value)} placeholder="Search references, tags, source…" /></div>
|
| 68 |
+
<div class="library-grid">
|
| 69 |
+
<For each={items()}>{(item) => (
|
| 70 |
+
<article class="library-tile" draggable onDragStart={(e) => {
|
| 71 |
+
e.dataTransfer?.setData("application/x-muse-library-item", JSON.stringify(item));
|
| 72 |
+
e.dataTransfer!.effectAllowed = "copy";
|
| 73 |
+
}} onClick={() => setSelected(item)}>
|
| 74 |
+
<img src={item.data_url} draggable={false}/>
|
| 75 |
+
<span class={`resolution-badge ${badgeClass(item)}`}>{item.width}×{item.height}</span>
|
| 76 |
+
<div class="tile-caption"><ImageIcon size={14}/><span>{item.title}</span></div>
|
| 77 |
+
</article>
|
| 78 |
+
)}</For>
|
| 79 |
+
</div>
|
| 80 |
+
</main>
|
| 81 |
+
<aside class="library-detail">
|
| 82 |
+
<Show when={selected()} fallback={<div class="empty-detail"><ImageIcon size={42}/><p>Select a reference for details.</p></div>}>
|
| 83 |
+
{(item) => <>
|
| 84 |
+
<img class="detail-preview" src={item().data_url}/>
|
| 85 |
+
<h2>{item().title}</h2>
|
| 86 |
+
<p class="muted">{item().width} × {item().height}</p>
|
| 87 |
+
<p class="detail-source">{item().url}</p>
|
| 88 |
+
<div class="palette-row"><Palette size={16}/>{item().colors.map(c => <button title={c} class="swatch" style={{"background-color": c}} />)}</div>
|
| 89 |
+
<button class="secondary-wide" onClick={() => void invoke("board_add_palette", { colors: item().colors, x: 80, y: 80 })}>Send palette to Board</button>
|
| 90 |
+
</>}
|
| 91 |
+
</Show>
|
| 92 |
+
</aside>
|
| 93 |
+
</div>
|
| 94 |
+
</section>
|
| 95 |
+
);
|
| 96 |
+
}
|
src/components/browser/BrowserChrome.tsx
CHANGED
|
@@ -139,10 +139,38 @@ export default function BrowserChrome(props: { active: boolean }) {
|
|
| 139 |
</button>
|
| 140 |
)}</For>
|
| 141 |
</div>
|
| 142 |
-
<
|
| 143 |
-
<ShieldCheck size={16} />
|
| 144 |
<span>{totalBlocked()} blocked · {shield().engine_rules} rules</span>
|
| 145 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
</aside>
|
| 147 |
|
| 148 |
<div classList={{ "browser-toolbar": true, inactive: !props.active }}>
|
|
@@ -165,26 +193,7 @@ export default function BrowserChrome(props: { active: boolean }) {
|
|
| 165 |
{totalBlocked()}
|
| 166 |
</button>
|
| 167 |
</div>
|
| 168 |
-
|
| 169 |
-
<div class="shield-popover">
|
| 170 |
-
<div class="shield-popover-header">
|
| 171 |
-
<strong>Muse Shield</strong>
|
| 172 |
-
<button type="button" class="shield-close" onClick={() => setShieldOpen(false)}><X size={14} /></button>
|
| 173 |
-
</div>
|
| 174 |
-
<div class="shield-stats-grid">
|
| 175 |
-
<div class="shield-stat"><span class="shield-stat-value">{shield().blocked_requests}</span><span class="shield-stat-label">Requests blocked</span></div>
|
| 176 |
-
<div class="shield-stat"><span class="shield-stat-value">{shield().blocked_cosmetic}</span><span class="shield-stat-label">Elements hidden</span></div>
|
| 177 |
-
<div class="shield-stat"><span class="shield-stat-value">{shield().https_upgrades}</span><span class="shield-stat-label">HTTPS upgrades</span></div>
|
| 178 |
-
<div class="shield-stat"><span class="shield-stat-value">{shield().engine_rules}</span><span class="shield-stat-label">Filter rules</span></div>
|
| 179 |
-
</div>
|
| 180 |
-
<Show when={activeTab()}>
|
| 181 |
-
<div class="shield-domain-toggle">
|
| 182 |
-
<span>Shield for <strong>{hostLabel(activeTab()!.url)}</strong></span>
|
| 183 |
-
<button type="button" classList={{ "toggle-btn": true, off: domainAllowed() }} onClick={() => void toggleDomainShield()}>
|
| 184 |
-
{domainAllowed() ? "Paused" : "Active"}
|
| 185 |
-
</button>
|
| 186 |
-
</div>
|
| 187 |
-
</Show>
|
| 188 |
<div class="shield-actions">
|
| 189 |
<button type="button" class="shield-action-btn" onClick={() => void updateLists()}>
|
| 190 |
<RefreshCw size={14} /> Update filter lists
|
|
|
|
| 139 |
</button>
|
| 140 |
)}</For>
|
| 141 |
</div>
|
| 142 |
+
<button type="button" class="sidebar-shield-summary clickable" onClick={() => setShieldOpen(!shieldOpen())}>
|
| 143 |
+
{domainAllowed() ? <ShieldOff size={16} /> : <ShieldCheck size={16} />}
|
| 144 |
<span>{totalBlocked()} blocked · {shield().engine_rules} rules</span>
|
| 145 |
+
</button>
|
| 146 |
+
<Show when={shieldOpen()}>
|
| 147 |
+
<div class="shield-sidebar-panel">
|
| 148 |
+
<div class="shield-popover-header">
|
| 149 |
+
<strong>Muse Shield</strong>
|
| 150 |
+
<button type="button" class="shield-close" onClick={() => setShieldOpen(false)}><X size={14} /></button>
|
| 151 |
+
</div>
|
| 152 |
+
<div class="shield-stats-grid">
|
| 153 |
+
<div class="shield-stat"><span class="shield-stat-value">{shield().blocked_requests}</span><span class="shield-stat-label">Requests</span></div>
|
| 154 |
+
<div class="shield-stat"><span class="shield-stat-value">{shield().blocked_cosmetic}</span><span class="shield-stat-label">Hidden</span></div>
|
| 155 |
+
<div class="shield-stat"><span class="shield-stat-value">{shield().https_upgrades}</span><span class="shield-stat-label">HTTPS</span></div>
|
| 156 |
+
<div class="shield-stat"><span class="shield-stat-value">{shield().engine_rules}</span><span class="shield-stat-label">Rules</span></div>
|
| 157 |
+
</div>
|
| 158 |
+
<Show when={activeTab()}>
|
| 159 |
+
<div class="shield-domain-toggle compact">
|
| 160 |
+
<span><strong>{hostLabel(activeTab()!.url)}</strong></span>
|
| 161 |
+
<button type="button" classList={{ "toggle-btn": true, off: domainAllowed() }} onClick={() => void toggleDomainShield()}>
|
| 162 |
+
{domainAllowed() ? "Paused" : "Active"}
|
| 163 |
+
</button>
|
| 164 |
+
</div>
|
| 165 |
+
</Show>
|
| 166 |
+
<button type="button" class="shield-action-btn" onClick={() => void updateLists()}>
|
| 167 |
+
<RefreshCw size={14} /> Update lists
|
| 168 |
+
</button>
|
| 169 |
+
<div class="shield-footer sidebar-safe">
|
| 170 |
+
<span>HTTPS-first</span><span>WebRTC</span><span>Canvas noise</span><span>Cookie reject</span>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</Show>
|
| 174 |
</aside>
|
| 175 |
|
| 176 |
<div classList={{ "browser-toolbar": true, inactive: !props.active }}>
|
|
|
|
| 193 |
{totalBlocked()}
|
| 194 |
</button>
|
| 195 |
</div>
|
| 196 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
<div class="shield-actions">
|
| 198 |
<button type="button" class="shield-action-btn" onClick={() => void updateLists()}>
|
| 199 |
<RefreshCw size={14} /> Update filter lists
|
src/styles/app.css
CHANGED
|
@@ -113,3 +113,53 @@ h2 { margin: 4px 0 8px; font-size: 18px; }
|
|
| 113 |
|
| 114 |
@keyframes pulse { 0%,100% { opacity: .45; transform: scale(.9); } 50% { opacity: 1; transform: scale(1.1); } }
|
| 115 |
@media (max-width: 980px) { .app-shell { grid-template-columns: 248px minmax(0,1fr); } .browser-toolbar { grid-template-columns: auto 1fr; } .traffic-group.right { display:none; } .dashboard-grid.mature { grid-template-columns: 1fr; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
@keyframes pulse { 0%,100% { opacity: .45; transform: scale(.9); } 50% { opacity: 1; transform: scale(1.1); } }
|
| 115 |
@media (max-width: 980px) { .app-shell { grid-template-columns: 248px minmax(0,1fr); } .browser-toolbar { grid-template-columns: auto 1fr; } .traffic-group.right { display:none; } .dashboard-grid.mature { grid-template-columns: 1fr; } }
|
| 116 |
+
|
| 117 |
+
/* Sidebar-safe shield panel: never overlaps native child WebView */
|
| 118 |
+
.sidebar-shield-summary.clickable { width: 100%; border: 0; background: transparent; cursor: pointer; text-align: left; }
|
| 119 |
+
.sidebar-shield-summary.clickable:hover { background: var(--bg-raised); color: var(--text-primary); }
|
| 120 |
+
.shield-sidebar-panel { margin: 0 10px 10px; padding: 14px; background: var(--bg-surface); border: 1px solid var(--glass-border); border-radius: 16px; box-shadow: 0 12px 30px rgba(0,0,0,.35); }
|
| 121 |
+
.shield-domain-toggle.compact { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 0; border-top: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border); margin-bottom: 10px; font-size: 12px; }
|
| 122 |
+
.shield-domain-toggle.compact span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 123 |
+
.shield-footer.sidebar-safe { margin-top: 10px; }
|
| 124 |
+
|
| 125 |
+
/* Phase 3 Library */
|
| 126 |
+
.library-module, .board-module { height: 100%; min-height: 0; display: flex; flex-direction: column; gap: 18px; }
|
| 127 |
+
.module-header { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
|
| 128 |
+
.module-header h1, .board-toolbar-panel h1 { margin: 0; font-size: 36px; letter-spacing: -0.04em; }
|
| 129 |
+
.library-addbar { display: flex; gap: 10px; min-width: 420px; }
|
| 130 |
+
.library-addbar input, .library-search input { flex: 1; height: 38px; border: 1px solid var(--glass-border); border-radius: 12px; background: var(--bg-surface); color: var(--text-primary); padding: 0 12px; outline: none; }
|
| 131 |
+
.primary.small { height: 38px; margin: 0; display: flex; align-items: center; gap: 6px; }
|
| 132 |
+
.library-layout { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) 320px; gap: 18px; }
|
| 133 |
+
.library-main, .library-detail { min-height: 0; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: 20px; padding: 16px; overflow: auto; }
|
| 134 |
+
.library-search { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
| 135 |
+
.library-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
|
| 136 |
+
.library-tile { position: relative; border: 1px solid var(--glass-border); border-radius: 16px; overflow: hidden; background: var(--bg-surface); cursor: pointer; min-height: 150px; }
|
| 137 |
+
.library-tile:hover { border-color: var(--accent); transform: translateY(-1px); }
|
| 138 |
+
.library-tile img { width: 100%; height: 130px; object-fit: cover; display: block; }
|
| 139 |
+
.tile-caption { height: 34px; display: flex; align-items: center; gap: 6px; padding: 0 10px; font-size: 12px; color: var(--text-secondary); }
|
| 140 |
+
.tile-caption span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 141 |
+
.resolution-badge { position: absolute; right: 8px; top: 8px; padding: 3px 7px; border-radius: 999px; background: rgba(0,0,0,.7); color: white; font-size: 11px; }
|
| 142 |
+
.resolution-badge.good { color: #7CFFB2; } .resolution-badge.warn { color: #FFD36A; } .resolution-badge.bad { color: #FF8A8A; }
|
| 143 |
+
.detail-preview { width: 100%; max-height: 260px; object-fit: contain; border-radius: 14px; background: #111; }
|
| 144 |
+
.detail-source { font-size: 12px; color: var(--text-muted); word-break: break-all; }
|
| 145 |
+
.palette-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 14px 0; }
|
| 146 |
+
.swatch { width: 28px; height: 28px; border-radius: 8px; border: 1px solid var(--glass-border); }
|
| 147 |
+
.secondary-wide { width: 100%; height: 36px; border: 1px solid var(--glass-border); border-radius: 10px; background: var(--bg-raised); color: var(--text-primary); cursor: pointer; }
|
| 148 |
+
.empty-detail { height: 100%; display: grid; place-items: center; color: var(--text-muted); text-align: center; }
|
| 149 |
+
|
| 150 |
+
/* Phase 3 Board */
|
| 151 |
+
.board-toolbar-panel { display: flex; align-items: center; gap: 12px; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: 18px; padding: 14px 16px; }
|
| 152 |
+
.board-toolbar-panel > div { margin-right: auto; }
|
| 153 |
+
.secondary { height: 34px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--glass-border); border-radius: 10px; background: var(--bg-surface); color: var(--text-secondary); cursor: pointer; padding: 0 12px; }
|
| 154 |
+
.secondary:hover { color: var(--text-primary); background: var(--bg-raised); }
|
| 155 |
+
.board-canvas { flex: 1; min-height: 0; position: relative; overflow: hidden; background: #11100e; border: 1px solid var(--glass-border); border-radius: 20px; touch-action: none; }
|
| 156 |
+
.board-grid-bg { position: absolute; inset: -200%; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 32px 32px; pointer-events: none; }
|
| 157 |
+
.board-world { position: absolute; inset: 0; }
|
| 158 |
+
.board-card { position: absolute; border: 1px solid rgba(255,255,255,.16); border-radius: 14px; background: var(--bg-surface); overflow: hidden; box-shadow: 0 12px 30px rgba(0,0,0,.35); cursor: grab; }
|
| 159 |
+
.board-card:active { cursor: grabbing; }
|
| 160 |
+
.board-card.image img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
| 161 |
+
.board-card.note textarea { width: 100%; height: 100%; resize: none; border: 0; outline: 0; padding: 14px; background: #F7E8B4; color: #2b2118; font: inherit; }
|
| 162 |
+
.board-palette { display: flex; width: 100%; height: 100%; padding: 12px; gap: 8px; align-items: stretch; }
|
| 163 |
+
.palette-chip { flex: 1; border-radius: 10px; border: 1px solid rgba(0,0,0,.25); }
|
| 164 |
+
.board-delete { position: absolute; top: 6px; right: 6px; width: 26px; height: 26px; border: 0; border-radius: 8px; background: rgba(0,0,0,.55); color: white; display: grid; place-items: center; opacity: 0; cursor: pointer; }
|
| 165 |
+
.board-card:hover .board-delete { opacity: 1; }
|