This commit is contained in:
Leon Liu 2024-12-31 17:53:47 +09:00
parent d51369d8f2
commit 5c5dd071cb
22 changed files with 781 additions and 345 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "heroicons"]
path = heroicons
url = git@github.com:tailwindlabs/heroicons.git

View File

@ -9,14 +9,22 @@ leptos = { version = "0.7.1", features = ["csr"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
console_error_panic_hook = "0.1.7"
reactive_stores = "0.1.1"
strum = "0.26.3"
strum_macros = "0.26.4"
serde_json = "1.0.133"
src-common = { path = "./src-common" }
src-common = { path = "./src-common", features = ["store"] }
reactive_stores = { workspace = true }
serde = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
getrandom = { version = "*", features = ["js"] }
[workspace]
members = ["src-common", "src-tauri"]
[workspace.dependencies]
strum = "0.26.3"
strum_macros = "0.26.4"
serde = { version = "1", features = ["derive"] }
reactive_stores = "0.1.2"
uuid = { version = "1.11.0", features = ["v4", "serde"] }
itertools = "0.13.0"

View File

@ -8,3 +8,15 @@ ignore = ["./src-tauri"]
port = 1420
open = false
ws_protocol = "ws"
[[hooks]]
stage = "build"
command = "bun"
command_arguments = [
"x",
"tailwindcss",
"-i",
"tailwind.css",
"-o",
"dist/.stage/tailwind.css",
]

BIN
bun.lockb

Binary file not shown.

60
generate-icons.ts Normal file
View File

@ -0,0 +1,60 @@
import * as fs from 'fs';
import * as path from 'path';
const inputFile: string = 'icon-list';
const outputFile: string = 'src/components/icons.rs';
const svgDir: string = 'heroicons/optimized';
function toComponentName(filePath: string): string {
const parts = filePath.split('/');
const size = parts[0];
const style = parts[1];
const fileName = parts[2].replace('.svg', '');
return fileName
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
+ size.charAt(0).toUpperCase() + size.slice(1)
+ style.charAt(0).toUpperCase() + style.slice(1);
}
function getSizeClass(size: string): string {
switch (size) {
case '16': return 'size-4';
case '20': return 'size-5';
case '24': return 'size-6';
default: return '';
}
}
function addClassToSvg(svgContent: string, sizeClass: string): string {
// Add the size class
svgContent = svgContent.replace('<svg', `<svg class="${sizeClass}"`);
return svgContent;
}
function generateComponents(): void {
const svgFiles: string[] = fs.readFileSync(inputFile, 'utf8').split('\n').filter(Boolean);
let output: string = 'use leptos::prelude::*;\n\n';
svgFiles.forEach((file: string) => {
const svgPath = `${file}.svg`;
const parts = file.split('/');
const size = parts[0];
let svgContent: string = fs.readFileSync(path.join(svgDir, svgPath), 'utf8');
const componentName: string = toComponentName(svgPath);
const sizeClass = getSizeClass(size);
svgContent = addClassToSvg(svgContent, sizeClass);
output += `#[component]\npub fn ${componentName}() -> impl IntoView {\n`;
output += ` view! { ${svgContent} }\n}\n\n`;
});
fs.writeFileSync(outputFile, output);
console.log('Components generated successfully.');
}
generateComponents();

1
heroicons Submodule

@ -0,0 +1 @@
Subproject commit fa902f44d071eac776758cfd6e0522cb7c37b1c3

8
icon-list Normal file
View File

@ -0,0 +1,8 @@
16/solid/plus
16/solid/folder-plus
16/solid/arrow-up
16/solid/arrow-down
16/solid/eye
16/solid/eye-slash
16/solid/chevron-down
16/solid/chevron-right

View File

@ -1,11 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Tauri + Leptos App</title>
<link data-trunk rel="tailwind-css" href="tailwind.css" />
<link data-trunk rel="copy-dir" href="public" />
<link data-trunk rel="rust" data-wasm-opt="z" />
</head>
<body></body>
</html>
<head>
<meta charset="utf-8" />
<title>Tauri + Leptos App</title>
<link rel="stylesheet" href="tailwind.css" />
<link data-trunk rel="rust" data-wasm-opt="z" />
</head>
<body></body>
</html>

View File

@ -4,7 +4,8 @@
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
"@types/bun": "latest",
"daisyui": "^4.12.23"
"daisyui": "^4.12.23",
"tailwindcss": "^3.4.17"
},
"peerDependencies": {
"typescript": "^5.0.0"

View File

@ -4,3 +4,10 @@ version = "0.1.0"
edition = "2021"
[dependencies]
uuid = { workspace = true }
serde = { workspace = true }
itertools = { workspace = true }
reactive_stores = { workspace = true }
[features]
store = []

View File

@ -1,14 +1 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
pub mod models;

View File

@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RangedNumber<const MIN: u32, const MAX: u32>(u32);
impl<const MIN: u32, const MAX: u32> RangedNumber<MIN, MAX> {
pub fn new(value: u32) -> Result<Self, String> {
if value < MIN || value > MAX {
Err(format!("Value {} is out of range ({} - {})", value, MIN, MAX))
} else {
Ok(RangedNumber(value))
}
}
pub fn min() -> u32 {MIN}
pub fn max() -> u32 {MAX}
pub fn value(&self) -> u32 {self.0}
}

View File

@ -0,0 +1,217 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use itertools::Itertools;
use uuid::Uuid;
use super::base::RangedNumber;
impl Filter {
pub fn default_leaf() -> Self {
Self {
id: Uuid::new_v4(),
enabled: true,
name: "".to_string(),
lines: HashMap::new(),
remain: FilterRemain::Leaf(Default::default()),
}
}
pub fn default_group() -> Self {
Self {
id: Uuid::new_v4(),
enabled: true,
name: "".to_string(),
lines: HashMap::new(),
remain: FilterRemain::Group(Default::default()),
}
}
}
#[cfg_attr(feature = "store", derive(reactive_stores::Store))]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Filter {
pub id: uuid::Uuid,
pub enabled: bool,
pub name: String,
pub lines: HashMap<String, Line>,
pub remain: FilterRemain,
}
#[cfg_attr(feature = "store", derive(reactive_stores::Store))]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum FilterRemain {
Leaf(FilterLeaf),
Group(FilterGroup),
}
#[cfg_attr(feature = "store", derive(reactive_stores::Store))]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FilterLeaf {
pub show: bool,
}
impl Default for FilterLeaf {
fn default() -> Self {
Self { show: true }
}
}
#[cfg_attr(feature = "store", derive(reactive_stores::Store))]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct FilterGroup {
#[cfg_attr(feature = "store", store(key: String = |row| row.id.to_string()))]
pub filters: Vec<Filter>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
pub type Level = RangedNumber<1, 100>;
#[cfg_attr(feature = "store", derive(reactive_stores::Store))]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum Line {
Class(Vec<ItemClass>),
BaseType(Vec<ItemBaseType>),
AreaLevel(Op, Level),
DropLevel(Op, Level),
ItemLevel(Op, Level),
Rarity(Op, ItemRarity),
Sockets(Op, u32),
Quality(Op, u32),
StackSize(Op, u32),
// waystones
WaystoneTier(Op, RangedNumber<1, 16>),
// effects
SetFontSize(RangedNumber<1, 45>),
SetTextColor(Color),
SetBorderColor(Color),
SetBackgroundColor(Color),
PlayAlertSound(RangedNumber<1, 16>, RangedNumber<0, 300>),
PlayEffect(GameColor),
MinimapIcon(RangedNumber<0, 2>, GameColor, MinimapIconShape),
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Settings {
base_types: Vec<String>,
classes: Vec<String>,
rarities: Vec<String>,
minimap_icon_shapes: Vec<String>,
game_colors: Vec<String>,
ops: Vec<String>,
}
impl Settings {
fn format_err(value: &str, category: &str, options: &Vec<String>) -> String {
format!(
"{} is not a valid {}, options are: {}",
value,
category,
options.iter().join(" ")
)
}
pub fn base_type(&self, s: &String) -> Result<ItemBaseType, String> {
if !self.base_types.contains(s) {
Err(Self::format_err(s, "BaseType", &self.base_types))
} else {
Ok(ItemBaseType(s.to_string()))
}
}
pub fn class(&self, s: &String) -> Result<ItemClass, String> {
if !self.classes.contains(s) {
Err(Self::format_err(s, "Class", &self.classes))
} else {
Ok(ItemClass(s.to_string()))
}
}
pub fn rarity(&self, s: &String) -> Result<ItemRarity, String> {
if !self.rarities.contains(s) {
Err(Self::format_err(s, "Rarity", &self.rarities))
} else {
Ok(ItemRarity(s.to_string()))
}
}
pub fn op(&self, s: &String) -> Result<Op, String> {
if !self.ops.contains(s) {
Err(Self::format_err(s, "Operator", &self.ops))
} else {
Ok(Op(s.to_string()))
}
}
pub fn minimap_icon_shape(&self, s: &String) -> Result<MinimapIconShape, String> {
if !self.minimap_icon_shapes.contains(s) {
Err(Self::format_err(
s,
"MinimapIconShape",
&self.minimap_icon_shapes,
))
} else {
Ok(MinimapIconShape(s.to_string()))
}
}
pub fn game_color(&self, s: &String) -> Result<GameColor, String> {
if !self.game_colors.contains(s) {
Err(Self::format_err(s, "GameColor", &self.game_colors))
} else {
Ok(GameColor(s.to_string()))
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ItemBaseType(String);
impl ItemBaseType {
pub fn value(&self) -> &str {
&self.0
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ItemClass(String);
impl ItemClass {
pub fn value(&self) -> &str {
&self.0
}
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ItemRarity(String);
impl ItemRarity {
pub fn value(&self) -> &str {
&self.0
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MinimapIconShape(String);
impl MinimapIconShape {
pub fn value(&self) -> &str {
&self.0
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GameColor(String);
impl GameColor {
pub fn value(&self) -> &str {
&self.0
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Op(String);
impl Op {
pub fn value(&self) -> &str {
&self.0
}
}

View File

@ -0,0 +1,2 @@
pub mod loot_filter;
pub mod base;

View File

@ -20,9 +20,6 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
strum_macros = "0.26.4"
strum = "0.26.3"
reqwest = { version = "0.12.9", features = ["blocking"] }
serde_json = "1.0.133"
thiserror = "2.0.8"
@ -30,3 +27,6 @@ directories = "5.0.1"
indoc = "2.0.5"
open = "5.3.1"
src-common = { path = "../src-common" }
serde = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }

View File

@ -1,11 +1,14 @@
use leptos::prelude::{Read as Reads, *};
use reactive_stores::Store;
use leptos::{logging::log, prelude::*, tachys::reactive_graph::bind::IntoSplitSignal};
use reactive_stores::{Field, Store, StoreField};
use serde::{Deserialize, Serialize};
use serde_wasm_bindgen::from_value;
use src_common::models::loot_filter::{Filter, FilterStoreFields};
use strum_macros::EnumIter;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::components::{self, filter_detail::FilterDetail, filter_root::FilterRoot, icons};
#[wasm_bindgen]
extern "C" {
// invoke without arguments
@ -18,326 +21,108 @@ extern "C" {
// They need to have different names!
}
#[derive(Store, Debug, Clone, Serialize, Deserialize)]
struct POE2FilterConfig {
#[store(key: String = |row| row.kind.to_string())]
armours: Vec<ArmourFilter>,
#[store(key: String = |row| row.kind.to_string())]
weapons: Vec<WeaponFilter>,
#[store(key: String = |row| row.kind.to_string())]
accessories: Vec<AccessoryFiler>,
settings: FilterSettings,
}
#[derive(Store, Debug, Clone, Serialize, Deserialize, Default)]
struct FilterSettings {
max_level: u32,
}
#[derive(Store, Debug, Clone, strum_macros::Display, EnumIter, Serialize, Deserialize)]
#[strum(serialize_all = "title_case")]
enum ArmourKind {
BodyArmours,
Helmets,
Boots,
Gloves,
}
#[derive(Store, Debug, Clone, strum_macros::Display, EnumIter, Serialize, Deserialize)]
#[strum(serialize_all = "title_case")]
enum WeaponKind {
OneHandMaces,
TwoHandMaces,
Crossbows,
Bows,
Quivers,
Sceptres,
Wands,
Staves,
Quarterstaves,
Shields,
Foci,
}
#[derive(Store, Debug, Clone, strum_macros::Display, EnumIter, Serialize, Deserialize)]
#[strum(serialize_all = "title_case")]
enum AccessoryKind {}
#[derive(Store, Debug, Clone, Serialize, Deserialize)]
struct ArmourFilter {
kind: ArmourKind,
armour: bool,
evasion: bool,
energy_shield: bool,
armour_evasion: bool,
armour_energy_shield: bool,
evasion_energy_shield: bool,
}
#[derive(Store, Debug, Clone, Serialize, Deserialize)]
struct WeaponFilter {
kind: WeaponKind,
show: bool,
}
#[derive(Store, Debug, Clone, Serialize, Deserialize)]
struct AccessoryFiler {
kind: AccessoryKind,
}
#[derive(Serialize, Deserialize)]
struct UpdateArgs {
config: POE2FilterConfig,
#[derive(Store, Clone)]
struct AppStore {
root: Filter,
selected_filter: Option<Field<Filter>>,
}
#[component]
fn Main(config: POE2FilterConfig) -> impl IntoView {
let store = Store::new(config);
Effect::new(move |_| {
let config = store.get();
spawn_local(async move {
let args = serde_wasm_bindgen::to_value(&UpdateArgs { config: config }).unwrap();
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
invoke("update", args).await;
});
fn Main() -> impl IntoView {
let store = Store::new(AppStore {
root: Filter::default_group(),
selected_filter: None,
});
let filter = store.root();
// Effect::new(move |_| {
// let config = store.get();
// spawn_local(async move {
// let args = serde_wasm_bindgen::to_value(&UpdateArgs { config: config }).unwrap();
// // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
// invoke("update", args).await;
// });
// });
let right = move || {
let selected = store.selected_filter().get();
match selected {
Some(selected) => {
if selected.id().get() == store.root().id().get() {
FilterRoot().into_any()
} else {
view! { <FilterDetail filter=selected /> }.into_any()
}
}
None => FilterRoot().into_any(),
}
};
let max_level = store.settings().max_level();
view! {
<main class="container p-4 prose max-w-none">
<ul>
<li class="not-prose">
"Config folder: "
<button
class="btn btn-xs btn-link"
on:click=|_| {
spawn_local(async {
invoke_without_args("open_config_folder").await;
});
}
>
"%appdata%\\LeonLiu\\POE2 Loot Filter\\config"
</button>
</li>
<li>
"Changes on the UI will immediately write the updated filter file into POE2's config folder. Reload the filter in-game after changes on the UI."
</li>
<li>"The output filter is named " <strong>Leon</strong>.</li>
<li>
"Based on "
<a
target="_blank"
href="https://github.com/NeverSinkDev/NeverSink-PoE2litefilter"
>
NeverSink-PoE2litefilter
</a>, will download on every app launch, saved as
" base_filter.filter in the config folder".
</li>
<li class="not-prose">
"In areas lower than level"
<input
class="input input-xs w-12 input-bordered"
type="number"
min="60"
max="100"
prop:value=max_level.get()
on:change:target=move |ev| {
*store.settings().max_level().write() = ev
.target()
.value()
.parse::<u32>()
.unwrap_or(70);
}
/> ",hide normal or magic items, unless turned on in configurations below."
</li>
<li>
{move || {
format!(
"From area level 11 to {}, hide normal or magic items of which the drop level is 10 level lower than the area level.",
max_level.get(),
)
}}
</li>
</ul>
<h2>Weapons</h2>
<p>
{move || {
format!(
"In areas lower than level {}, only show normal or magic weapons selected.",
max_level.get(),
)
}}
</p>
<div class="flex flex-wrap">
<For
each=move || store.weapons()
key=|row| row.read().kind.to_string()
children=|child| {
let kind = child.clone().kind().get().to_string();
let show = child.show();
view! {
<label class="label cursor-pointer">
<input
type="checkbox"
class="checkbox"
prop:checked=show.get()
on:change:target=move |ev| {
*show.write() = event_target_checked(&ev);
}
/>
<span class="ml-1 label-text">{kind.clone()}</span>
</label>
}
}
/>
</div>
<h2>Armours</h2>
<p>
{move || {
format!(
"In areas lower than level {}, only show normal or magic armours with selected base defence types.",
max_level.get(),
)
}}
</p>
<div>
<For
each=move || store.armours()
key=|row| row.read().kind.to_string()
children=|child| {
let kind = child.clone().kind().get().to_string();
let armour = child.clone().armour();
let evasion = child.clone().evasion();
let energy_shield = child.clone().energy_shield();
let armour_evasion = child.clone().armour_evasion();
let armour_energy_shield = child.clone().armour_energy_shield();
let evasion_energy_shield = child.clone().evasion_energy_shield();
view! {
<h3>{kind.clone()}</h3>
<div class="flex flex-wrap">
<label class="label cursor-pointer">
<input
type="checkbox"
class="checkbox"
id=kind.clone() + "Armour"
name=kind.clone() + "Armour"
prop:checked=armour.get()
on:change:target=move |ev| {
*armour.write() = event_target_checked(&ev);
}
/>
<span class="ml-1 label-text">"Armour"</span>
</label>
<label class="label cursor-pointer">
<input
type="checkbox"
class="checkbox"
id=kind.clone() + "Evasion"
name=kind.clone() + "Evasion"
prop:checked=evasion.get()
on:change:target=move |ev| {
*evasion.write() = event_target_checked(&ev);
}
/>
<span class="ml-1 label-text">"Evasion"</span>
</label>
<label class="label cursor-pointer">
<input
type="checkbox"
class="checkbox"
id=kind.clone() + "EnergyShield"
name=kind.clone() + "EnergyShield"
prop:checked=energy_shield.get()
on:change:target=move |ev| {
*energy_shield.write() = event_target_checked(&ev);
}
/>
<span class="ml-1 label-text">"Energy Shield"</span>
</label>
<label class="label cursor-pointer">
<input
type="checkbox"
class="checkbox"
id=kind.clone() + "ArmourEvasion"
name=kind.clone() + "ArmourEvasion"
prop:checked=armour_evasion.get()
on:change:target=move |ev| {
*armour_evasion.write() = event_target_checked(&ev);
}
/>
<span class="ml-1 label-text">"Armour + Evasion"</span>
</label>
<label class="label cursor-pointer">
<input
type="checkbox"
class="checkbox"
id=kind.clone() + "ArmourEnergyShield"
name=kind.clone() + "ArmourEnergyShield"
prop:checked=armour_energy_shield.get()
on:change:target=move |ev| {
*armour_energy_shield.write() = event_target_checked(&ev);
}
/>
<span class="ml-1 label-text">"Armour + Energy Shield"</span>
</label>
<label class="label cursor-pointer">
<input
type="checkbox"
class="checkbox"
id=kind.clone() + "EvasionEnergyShield"
name=kind.clone() + "EvasionEnergyShield"
prop:checked=evasion_energy_shield.get()
on:change:target=move |ev| {
*evasion_energy_shield.write() = event_target_checked(&ev);
}
/>
<span class="ml-1 label-text">"Evasion + Energy Shield"</span>
</label>
</div>
}
}
/>
<main class="container p-4 max-w-none h-screen">
<article class="prose">
<h2>POE2 Loot Filter Config</h2>
</article>
<div class="flex">
<div class="flex-1">
<ul class="menu menu-xs">
<components::filter::Filter
filter=filter
selected=store.selected_filter()
on_action=move |action| {
log!("action");
if let components::filter::Action::Select(f) = action {
log!("select");
store.selected_filter().set(Some(f));
}
}
root=true
/>
</ul>
</div>
<div class="flex-1">{right}</div>
</div>
</main>
}
}
async fn load_data() -> POE2FilterConfig {
from_value(invoke_without_args("get_config").await).unwrap()
}
// async fn load_data() -> POE2FilterConfig {
// from_value(invoke_without_args("get_config").await).unwrap()
// }
#[component]
pub fn App() -> impl IntoView {
let config = LocalResource::new(move || load_data());
view! {
<Suspense fallback=move || {
view! {
<main class="flex items-center justify-center h-screen">
<div class="inline-flex items-center justify-start gap-1">
<svg
role="status"
class="mr-2 h-8 w-8 animate-spin fill-green-600 text-gray-100 dark:text-gray-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
></path>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
></path>
</svg>
<span>Loading...</span>
</div>
</main>
}
}>
{move || {
config.get().as_deref().map(|config| view! { <Main config=config.clone() /> })
}}
</Suspense>
}
view! { <Main /> }
// let config = LocalResource::new(move || load_data());
// view! {
// <Suspense fallback=move || {
// view! {
// <main class="flex items-center justify-center h-screen">
// <div class="inline-flex items-center justify-start gap-1">
// <svg
// role="status"
// class="mr-2 h-8 w-8 animate-spin fill-green-600 text-gray-100 dark:text-gray-600"
// viewBox="0 0 100 101"
// fill="none"
// xmlns="http://www.w3.org/2000/svg"
// >
// <path
// d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
// fill="currentColor"
// ></path>
// <path
// d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
// fill="currentFill"
// ></path>
// </svg>
// <span>Loading...</span>
// </div>
// </main>
// }
// }>
// {move || {
// config.get().as_deref().map(|config| view! { <Main config=config.clone() /> })
// }}
// </Suspense>
// }
}

231
src/components/filter.rs Normal file
View File

@ -0,0 +1,231 @@
use leptos::{either::Either, ev::MouseEvent, prelude::*};
use reactive_stores::Field;
use src_common::models::loot_filter::{
Filter, FilterGroup, FilterGroupStoreFields, FilterLeaf, FilterLeafStoreFields, FilterRemain,
FilterRemainStoreFields, FilterStoreFields,
};
use crate::components::icons;
fn group(
filter: Field<Filter>,
group: Field<FilterGroup>,
on_action: Callback<(Action,)>,
selected: Field<Option<Field<Filter>>>,
root: bool,
) -> impl IntoView {
let header = move || {
if root {
"Root".to_string()
} else {
filter.id().get().to_string()
}
};
let (expanded, set_expanded) = signal(false);
let new_rule = move |ev: MouseEvent| {
ev.stop_propagation();
group.filters().write().push(Filter::default_leaf());
set_expanded.set(true);
};
let new_group = move |ev: MouseEvent| {
ev.stop_propagation();
group.filters().write().push(Filter::default_group());
set_expanded.set(true);
};
let nav_icon = move || {
view! {
<button on:click=move |ev| {
ev.stop_propagation();
set_expanded
.update(|v| {
*v = !*v;
});
}>
{if expanded.get() {
Either::Left(view! { <icons::ChevronDown16Solid /> })
} else {
Either::Right(view! { <icons::ChevronRight16Solid /> })
}}
</button>
}
};
let active = move || match selected.get() {
Some(f) => filter.id().get() == f.id().get(),
None => false,
};
view! {
<li>
<a
class=("active", active)
on:click=move |_| {
on_action.run((Action::Select(filter),));
}
>
<div class="flex gap-2">
{nav_icon}
<button on:click=move |ev| {
ev.stop_propagation();
on_action.run((Action::Up,));
}>
<icons::ArrowUp16Solid />
</button>
<button on:click=move |ev| {
ev.stop_propagation();
on_action.run((Action::Down,));
}>
<icons::ArrowDown16Solid />
</button>
</div>
{header}
<div class="flex gap-2">
<button class="tooltip" data-tip="New Rule">
<icons::Plus16Solid on:click=new_rule />
</button>
<button class="tooltip" data-tip="New Group">
<icons::FolderPlus16Solid on:click=new_group />
</button>
<input
type="checkbox"
class="toggle toggle-xs"
disabled=root
prop:checked=move || filter.enabled().get()
on:click=move |ev| {
ev.stop_propagation();
}
on:change:target=move |ev| {
filter.enabled().set(event_target_checked(&ev));
}
/>
</div>
</a>
<ul class=("hidden", move || !expanded.get())>
<For
each=move || group.filters()
key=|row| row.read().id.to_string()
children=move |filter| {
let id = filter.clone().id().get();
view! {
<Filter
filter
selected
on_action=move |action| {
let i = group
.filters()
.into_iter()
.position(|f| f.id().get() == id)
.unwrap();
match action {
Action::Up => {
if i > 0 {
group.filters().write().swap(i, i - 1);
}
}
Action::Down => {
if i < group.filters().get().len() - 1 {
group.filters().write().swap(i, i + 1);
}
}
a @ Action::Select(_) => {
on_action.run((a,));
}
}
}
/>
}
}
/>
</ul>
</li>
}
}
fn leaf(
filter: Field<Filter>,
leaf: Field<FilterLeaf>,
on_action: Callback<(Action,)>,
selected: Field<Option<Field<Filter>>>,
) -> impl IntoView {
let header = move || filter.id().get().to_string();
let icon = move || {
if leaf.show().get() {
Either::Left(view! { <icons::Eye16Solid /> })
} else {
Either::Right(view! { <icons::EyeSlash16Solid /> })
}
};
let active = move || match selected.get() {
Some(f) => filter.id().get() == f.id().get(),
None => false,
};
view! {
<li>
<a
class=("active", active)
on:click=move |_| {
on_action.run((Action::Select(filter),));
}
>
<div class="flex gap-2">
{icon}
<button on:click=move |ev| {
ev.stop_propagation();
on_action.run((Action::Up,));
}>
<icons::ArrowUp16Solid />
</button>
<button on:click=move |ev| {
ev.stop_propagation();
on_action.run((Action::Down,));
}>
<icons::ArrowDown16Solid />
</button>
</div>
{header}
<div class="flex gap-2">
<input
type="checkbox"
class="toggle toggle-xs"
prop:checked=move || filter.enabled().get()
on:click=move |ev| {
ev.stop_propagation();
}
on:change:target=move |ev| {
filter.enabled().set(event_target_checked(&ev));
}
/>
</div>
</a>
</li>
}
}
pub enum Action {
Up,
Down,
Select(Field<Filter>),
}
#[component]
pub fn Filter(
#[prop(into)] filter: Field<Filter>,
#[prop(into)] on_action: Callback<(Action,)>,
#[prop(into)] selected: Field<Option<Field<Filter>>>,
#[prop(optional)] root: bool,
) -> impl IntoView {
let remain = filter.remain();
match remain.get_untracked() {
FilterRemain::Leaf(_) => {
leaf(filter, remain.leaf_0().unwrap().into(), on_action, selected).into_any()
}
FilterRemain::Group(_) => group(
filter,
remain.group_0().unwrap().into(),
on_action,
selected,
root,
)
.into_any(),
}
}

View File

@ -0,0 +1,12 @@
use leptos::prelude::*;
use reactive_stores::Field;
use src_common::models::loot_filter::Filter;
#[component]
pub fn FilterDetail(#[prop(into)] filter: Field<Filter>) -> impl IntoView {
view! {
<article class="prose">
<h3>Filter details:</h3>
</article>
}
}

View File

@ -0,0 +1,10 @@
use leptos::prelude::*;
#[component]
pub fn FilterRoot() -> impl IntoView {
view! {
<article class="prose">
<h3>How to use:</h3>
</article>
}
}

68
src/components/icons.rs Normal file
View File

@ -0,0 +1,68 @@
use leptos::prelude::*;
#[component]
pub fn Plus16Solid() -> impl IntoView {
view! { <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-slot="icon">
<path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"/>
</svg>
}
}
#[component]
pub fn FolderPlus16Solid() -> impl IntoView {
view! { <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd" d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5ZM8 6a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0v-1.5h-1.5a.75.75 0 0 1 0-1.5h1.5v-1.5A.75.75 0 0 1 8 6Z" clip-rule="evenodd"/>
</svg>
}
}
#[component]
pub fn ArrowUp16Solid() -> impl IntoView {
view! { <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd" d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z" clip-rule="evenodd"/>
</svg>
}
}
#[component]
pub fn ArrowDown16Solid() -> impl IntoView {
view! { <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd" d="M8 2a.75.75 0 0 1 .75.75v8.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.22 3.22V2.75A.75.75 0 0 1 8 2Z" clip-rule="evenodd"/>
</svg>
}
}
#[component]
pub fn Eye16Solid() -> impl IntoView {
view! { <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-slot="icon">
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/>
<path fill-rule="evenodd" d="M1.38 8.28a.87.87 0 0 1 0-.566 7.003 7.003 0 0 1 13.238.006.87.87 0 0 1 0 .566A7.003 7.003 0 0 1 1.379 8.28ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd"/>
</svg>
}
}
#[component]
pub fn EyeSlash16Solid() -> impl IntoView {
view! { <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd" d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l10.5 10.5a.75.75 0 1 0 1.06-1.06l-1.322-1.323a7.012 7.012 0 0 0 2.16-3.11.87.87 0 0 0 0-.567A7.003 7.003 0 0 0 4.82 3.76l-1.54-1.54Zm3.196 3.195 1.135 1.136A1.502 1.502 0 0 1 9.45 8.389l1.136 1.135a3 3 0 0 0-4.109-4.109Z" clip-rule="evenodd"/>
<path d="m7.812 10.994 1.816 1.816A7.003 7.003 0 0 1 1.38 8.28a.87.87 0 0 1 0-.566 6.985 6.985 0 0 1 1.113-2.039l2.513 2.513a3 3 0 0 0 2.806 2.806Z"/>
</svg>
}
}
#[component]
pub fn ChevronDown16Solid() -> impl IntoView {
view! { <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd" d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
</svg>
}
}
#[component]
pub fn ChevronRight16Solid() -> impl IntoView {
view! { <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
</svg>
}
}

4
src/components/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod filter;
pub mod filter_detail;
pub mod filter_root;
pub mod icons;

View File

@ -1,4 +1,5 @@
mod app;
mod components;
use app::*;
use leptos::prelude::*;
@ -6,8 +7,6 @@ use leptos::prelude::*;
fn main() {
console_error_panic_hook::set_once();
mount_to_body(|| {
view! {
<App/>
}
view! { <App /> }
})
}