├── .github └── workflows │ ├── publish-draft.yml │ └── test-push-and-pr.yml ├── .gitignore ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── README.md ├── assets ├── fonts │ ├── helvetica-bold.ttf │ ├── helvetica.ttf │ └── smme.ttf ├── icons │ ├── add.svg │ ├── add_white.svg │ ├── delete.svg │ ├── delete_white.svg │ ├── down_arrow.svg │ ├── down_arrow_red.svg │ ├── easy.png │ ├── expert.png │ ├── icon.png │ ├── normal.png │ ├── settings.svg │ ├── sort.svg │ ├── sort_white.svg │ ├── superexpert.png │ ├── up_arrow.svg │ ├── up_arrow_green.svg │ └── upload.svg └── screenshot.png ├── rust-toolchain └── src ├── app.rs ├── components ├── course_panel.rs ├── mod.rs ├── save_button.rs ├── smmdb_course_panel.rs └── voting_panel.rs ├── download.rs ├── emu ├── mod.rs └── save.rs ├── font.rs ├── icon.rs ├── main.rs ├── pages ├── init.rs ├── mod.rs ├── save.rs └── settings.rs ├── settings.rs ├── smmdb.rs ├── styles.rs └── widgets ├── courses_widget.rs ├── mod.rs ├── save_widget.rs ├── smmdb_widget.rs └── uploads_widget.rs /.github/workflows/publish-draft.yml: -------------------------------------------------------------------------------- 1 | name: Publish Draft 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | publish-linux: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Install dependencies 17 | run: | 18 | sudo apt-get update && \ 19 | sudo apt-get install -y --no-install-recommends libgtk-3-dev 20 | - name: Install nightly 21 | uses: actions-rs/toolchain@v1 22 | with: 23 | toolchain: nightly-2021-04-25 24 | override: true 25 | - name: Set environment variables 26 | run: | 27 | echo "VERSION=$(cat Cargo.toml | grep version | head -1 | sed 's/[\",(version = )]//g')" >> $GITHUB_ENV 28 | - name: Build 29 | uses: actions-rs/cargo@v1 30 | with: 31 | command: build 32 | args: --release 33 | - name: Compress release 34 | env: 35 | GZIP: -9 36 | run: | 37 | cd target/release && \ 38 | tar czvf smmdb-client-linux.tar.gz smmdb 39 | - name: Update Draft Release 40 | uses: ncipollo/release-action@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | with: 44 | token: ${{ secrets.GITHUB_TOKEN }} 45 | commit: ${{ github.sha }} 46 | tag: ${{ github.sha }} 47 | name: "SMMDB Client ${{ env.VERSION }} [Run#: ${{ github.run_number }}]" 48 | artifacts: "target/release/smmdb-client-linux.tar.gz" 49 | draft: true 50 | allowUpdates: true 51 | 52 | publish-windows: 53 | runs-on: windows-latest 54 | 55 | steps: 56 | - uses: actions/checkout@v2 57 | - name: Install nightly 58 | uses: actions-rs/toolchain@v1 59 | with: 60 | toolchain: nightly-2021-04-25 61 | override: true 62 | - name: Set environment variables 63 | run: | 64 | echo "VERSION=$(cat Cargo.toml | grep version | head -1 | sed 's/[\",(version = )]//g')" >> $GITHUB_ENV 65 | - name: Build 66 | uses: actions-rs/cargo@v1 67 | with: 68 | command: build 69 | args: --release 70 | - name: Compress release 71 | env: 72 | GZIP: -9 73 | run: | 74 | cd target/release && \ 75 | tar czvf smmdb-client-windows.tar.gz smmdb.exe 76 | shell: bash 77 | - name: Update Draft Release 78 | uses: ncipollo/release-action@v1 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | with: 82 | token: ${{ secrets.GITHUB_TOKEN }} 83 | commit: ${{ github.sha }} 84 | tag: ${{ github.sha }} 85 | name: "SMMDB Client ${{ env.VERSION }} [Run#: ${{ github.run_number }}]" 86 | artifacts: "target/release/smmdb-client-windows.tar.gz" 87 | draft: true 88 | allowUpdates: true 89 | 90 | publish-macos: 91 | runs-on: macos-latest 92 | 93 | steps: 94 | - uses: actions/checkout@v2 95 | - name: Install nightly 96 | uses: actions-rs/toolchain@v1 97 | with: 98 | toolchain: nightly-2021-04-25 99 | override: true 100 | - name: Set environment variables 101 | run: | 102 | echo "VERSION=$(cat Cargo.toml | grep version | head -1 | sed 's/[\",(version = )]//g')" >> $GITHUB_ENV 103 | - name: Build 104 | uses: actions-rs/cargo@v1 105 | with: 106 | command: build 107 | args: --release 108 | - name: Compress release 109 | env: 110 | GZIP: -9 111 | run: | 112 | cd target/release && \ 113 | tar czvf smmdb-client-macos.tar.gz smmdb 114 | - name: Update Draft Release 115 | uses: ncipollo/release-action@v1 116 | env: 117 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 118 | with: 119 | token: ${{ secrets.GITHUB_TOKEN }} 120 | commit: ${{ github.sha }} 121 | tag: ${{ github.sha }} 122 | name: "SMMDB Client ${{ env.VERSION }} [Run#: ${{ github.run_number }}]" 123 | artifacts: "target/release/smmdb-client-macos.tar.gz" 124 | draft: true 125 | allowUpdates: true 126 | -------------------------------------------------------------------------------- /.github/workflows/test-push-and-pr.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test-linux: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Install dependencies 19 | run: | 20 | sudo apt-get update && \ 21 | sudo apt-get install -y --no-install-recommends libgtk-3-dev 22 | - name: Install nightly 23 | uses: actions-rs/toolchain@v1 24 | with: 25 | toolchain: nightly-2021-04-25 26 | override: true 27 | components: rustfmt, clippy 28 | - name: Check 29 | uses: actions-rs/cargo@v1 30 | with: 31 | command: check 32 | - name: Fmt 33 | uses: actions-rs/cargo@v1 34 | with: 35 | command: fmt 36 | args: -- --check 37 | - name: Clippy 38 | uses: actions-rs/cargo@v1 39 | with: 40 | command: clippy 41 | args: -- -D warnings 42 | 43 | test-windows: 44 | runs-on: windows-latest 45 | 46 | steps: 47 | - uses: actions/checkout@v2 48 | - name: Install nightly 49 | uses: actions-rs/toolchain@v1 50 | with: 51 | toolchain: nightly-2021-04-25 52 | override: true 53 | components: rustfmt, clippy 54 | - name: Check 55 | uses: actions-rs/cargo@v1 56 | with: 57 | command: check 58 | - name: Fmt 59 | uses: actions-rs/cargo@v1 60 | with: 61 | command: fmt 62 | args: -- --check 63 | - name: Clippy 64 | uses: actions-rs/cargo@v1 65 | with: 66 | command: clippy 67 | 68 | test-macos: 69 | runs-on: macos-latest 70 | 71 | steps: 72 | - uses: actions/checkout@v2 73 | - name: Install nightly 74 | uses: actions-rs/toolchain@v1 75 | with: 76 | toolchain: nightly-2021-04-25 77 | override: true 78 | components: rustfmt, clippy 79 | - name: Check 80 | uses: actions-rs/cargo@v1 81 | with: 82 | command: check 83 | - name: Fmt 84 | uses: actions-rs/cargo@v1 85 | with: 86 | command: fmt 87 | args: -- --check 88 | - name: Clippy 89 | uses: actions-rs/cargo@v1 90 | with: 91 | command: clippy 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { "rust-analyzer.diagnostics.disabled": ["unresolved-macro-call"] } 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "smmdb" 3 | version = "0.4.0" 4 | authors = ["Mario Reder "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | anyhow = "1" 9 | dirs = "3" 10 | env_logger = "0.8" 11 | futures = "0.3" 12 | human-panic = "1" 13 | iced = { git = "https://github.com/hecrj/iced.git", rev = "40d21d23659bdb9fc6a6166208adb351e188846b", features = [ "image", "svg", "tokio" ] } 14 | iced_native = { git = "https://github.com/hecrj/iced.git", rev = "40d21d23659bdb9fc6a6166208adb351e188846b" } 15 | iced_wgpu = { git = "https://github.com/hecrj/iced.git", rev = "40d21d23659bdb9fc6a6166208adb351e188846b" } 16 | image = "0.23" 17 | indexmap = "1" 18 | lazy_static = "1" 19 | nfd = { version = "0.3", package = "nfd2" } 20 | reqwest = "0.11" 21 | serde = { version = "1", features = [ "derive" ] } 22 | serde_json = "1" 23 | serde_qs = "0.8" 24 | smmdb-lib = { version = "2", git = "https://github.com/Tarnadas/smmdb-lib.git", features = [ "save" ], package = "smmdb" } 25 | 26 | [profile] 27 | [profile.dev] 28 | opt-level = 1 29 | 30 | [profile.release] 31 | lto = "fat" 32 | codegen-units = 1 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SMMDB Client 2 | 3 | ![Continuous integration](https://github.com/Tarnadas/ninres-rs/workflows/Continuous%20integration/badge.svg) 4 | ![GitHub All Releases](https://img.shields.io/github/downloads/Tarnadas/smmdb-client/total) 5 | ![GitHub Releases](https://img.shields.io/github/downloads/Tarnadas/smmdb-client/latest/total) 6 | [![Discord](https://img.shields.io/discord/168893527357521920?label=Discord&logo=discord&color=7289da)](https://discord.gg/SPZsgSe) 7 | [![Twitter](https://img.shields.io/twitter/follow/marior_dev?style=flat&logo=twitter&label=follow&color=00acee)](https://twitter.com/marior_dev) 8 | 9 | Save file editor for Super Mario Maker 2. 10 | 11 | It will automatically detect your Yuzu and Ryujinx save folder, but you can also manually select any SMM2 save file on your system. 12 | 13 | This software lets you download courses from [SMMDB](https://smmdb.net). 14 | For planned features, please visit the [Github issue page](https://github.com/Tarnadas/smmdb-client/issues) 15 | 16 | ![](./assets/screenshot.png) 17 | 18 | ## Install 19 | 20 | You can download Windows, Linux and MacOS binaries in the [Github release section](https://github.com/Tarnadas/smmdb-client/releases) 21 | 22 | ### via Cargo 23 | 24 | You can install SMMDB Client via Cargo: 25 | 26 | It is recommended to install Cargo via [Rustup](https://rustup.rs/) 27 | 28 | #### Prerequisites (debian/ubuntu) 29 | 30 | Before installing the client, run the following commands: 31 | 32 | `sudo apt-get install cmake libfreetype6-dev libfontconfig1-dev xclip sudo libgtk-3-dev` 33 | 34 | #### nightly install (all OSs) 35 | 36 | After that, run these commands to fix rustup with nightly: 37 | 38 | `rustup install nightly` 39 | 40 | Set nightly as your default for now: 41 | 42 | `rustup default nightly` 43 | 44 | Now you can install the smmdb client: 45 | 46 | `cargo install --git https://github.com/Tarnadas/smmdb-client.git` 47 | 48 | Once you have installed smmdb, you can switch back to stable Rust: 49 | 50 | `rustup default stable` 51 | 52 | To open the smmdb client type `smmbd` in your terminal 53 | 54 | ### via Chocolatey (Windows Only) 55 | 56 | `choco install smmdb-client` 57 | 58 | Chocolatey install instructions/docs [Chocolatey.org](https://chocolatey.org/install) 59 | -------------------------------------------------------------------------------- /assets/fonts/helvetica-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tarnadas/smmdb-client/8a069e798c93d73f685357fe8d5962f53795e0a4/assets/fonts/helvetica-bold.ttf -------------------------------------------------------------------------------- /assets/fonts/helvetica.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tarnadas/smmdb-client/8a069e798c93d73f685357fe8d5962f53795e0a4/assets/fonts/helvetica.ttf -------------------------------------------------------------------------------- /assets/fonts/smme.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tarnadas/smmdb-client/8a069e798c93d73f685357fe8d5962f53795e0a4/assets/fonts/smme.ttf -------------------------------------------------------------------------------- /assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/add_white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/delete_white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/down_arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/down_arrow_red.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/easy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tarnadas/smmdb-client/8a069e798c93d73f685357fe8d5962f53795e0a4/assets/icons/easy.png -------------------------------------------------------------------------------- /assets/icons/expert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tarnadas/smmdb-client/8a069e798c93d73f685357fe8d5962f53795e0a4/assets/icons/expert.png -------------------------------------------------------------------------------- /assets/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tarnadas/smmdb-client/8a069e798c93d73f685357fe8d5962f53795e0a4/assets/icons/icon.png -------------------------------------------------------------------------------- /assets/icons/normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tarnadas/smmdb-client/8a069e798c93d73f685357fe8d5962f53795e0a4/assets/icons/normal.png -------------------------------------------------------------------------------- /assets/icons/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/sort.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/sort_white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/superexpert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tarnadas/smmdb-client/8a069e798c93d73f685357fe8d5962f53795e0a4/assets/icons/superexpert.png -------------------------------------------------------------------------------- /assets/icons/up_arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/up_arrow_green.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tarnadas/smmdb-client/8a069e798c93d73f685357fe8d5962f53795e0a4/assets/screenshot.png -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | nightly-2021-04-25 2 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | emu::*, 3 | icon, 4 | pages::{InitPage, SavePage, SettingsPage}, 5 | smmdb::{Course2Response, Difficulty, QueryParams, SmmdbUser, SortOptions}, 6 | styles::*, 7 | widgets::SmmdbTab, 8 | EmuSave, Page, Progress, Settings, Smmdb, 9 | }; 10 | 11 | use futures::future; 12 | use iced::{ 13 | button, container, executor, Application, Background, Button, Clipboard, Column, Command, 14 | Container, Element, Length, Row, Space, Subscription, 15 | }; 16 | use iced_native::{keyboard, subscription, Event}; 17 | use nfd::Response; 18 | use smmdb_lib::{CourseEntry, SavedCourse}; 19 | use std::convert::TryInto; 20 | 21 | pub struct App { 22 | state: AppState, 23 | error_state: AppErrorState, 24 | settings: Settings, 25 | current_page: Page, 26 | smmdb: Smmdb, 27 | _window_size: WindowSize, 28 | settings_button: button::State, 29 | } 30 | 31 | #[derive(Clone, Debug, PartialEq)] 32 | pub enum AppState { 33 | Default, 34 | Loading, 35 | UploadSelect(SavedCourse), 36 | SwapSelect(usize), 37 | DownloadSelect(usize), 38 | DeleteSelect(usize), 39 | DeleteSmmdbSelect(String), 40 | Downloading { 41 | save_index: usize, 42 | smmdb_id: String, 43 | progress: f32, 44 | }, 45 | } 46 | 47 | #[derive(Clone, Debug)] 48 | pub enum AppErrorState { 49 | Some(String), 50 | None, 51 | } 52 | 53 | #[derive(Clone, Debug)] 54 | pub enum Message { 55 | Empty, 56 | SetWindowSize(WindowSize), 57 | OpenSave(EmuSave), 58 | OpenCustomSave, 59 | LoadSave(Box, String), 60 | LoadSaveError(String), 61 | FetchSaveCourses(Vec), 62 | FetchCourses, 63 | FetchSelfCourses, 64 | FetchError(String), 65 | SetSaveCourseResponse(Vec), 66 | SetSmmdbCourses(Vec), 67 | SetSelfSmmdbCourses(Vec), 68 | SetSmmdbCourseThumbnail(Vec, String), 69 | SetSmmdbTab(SmmdbTab), 70 | InitUploadCourse(SavedCourse), 71 | UploadCourse(SavedCourse), 72 | UploadSucceeded(SavedCourse, String), 73 | InitSwapCourse(usize), 74 | SwapCourse(usize, usize), 75 | InitDownloadCourse(usize), 76 | DownloadCourse(usize, String), 77 | DownloadProgressed(Progress), 78 | InitDeleteCourse(usize), 79 | DeleteCourse(usize), 80 | InitDeleteSmmdbCourse(String), 81 | DeleteSmmdbCourse(String), 82 | ReloadAfterDelete(String), 83 | TitleChanged(String), 84 | UploaderChanged(String), 85 | DifficultyChanged(Difficulty), 86 | SortChanged(SortOptions), 87 | ApplyFilters, 88 | PaginateForward, 89 | PaginateBackward, 90 | PaginateSelfForward, 91 | PaginateSelfBackward, 92 | UpvoteCourse(String), 93 | DownvoteCourse(String), 94 | ResetCourseVote(String), 95 | SetVoteCourse(String, i32), 96 | OpenSettings, 97 | TrySaveSettings(Settings), 98 | SaveSettings((Settings, Option)), 99 | RejectSettings(String), 100 | CloseSettings, 101 | ChangeApiKey(String), 102 | ResetApiKey, 103 | ResetState, 104 | } 105 | 106 | #[derive(Clone, Debug)] 107 | pub enum WindowSize { 108 | S, 109 | M, 110 | } 111 | 112 | impl Application for App { 113 | type Executor = executor::Default; 114 | type Message = Message; 115 | type Flags = (); 116 | 117 | fn new(_flags: ()) -> (App, Command) { 118 | let components = guess_emu_dir().unwrap(); 119 | let settings = Settings::load().unwrap(); 120 | let smmdb = Smmdb::new(settings.apikey.clone()); 121 | let mut commands = vec![async move { Message::FetchCourses }.into()]; 122 | if let Some(apikey) = &settings.apikey { 123 | let settings = settings.clone(); 124 | commands.push(Command::perform( 125 | Smmdb::try_sign_in(apikey.clone()), 126 | move |res| match res { 127 | Ok(user) => Message::SaveSettings((settings.clone(), Some(user))), 128 | Err(err) => Message::FetchError(err), 129 | }, 130 | )) 131 | } 132 | ( 133 | App { 134 | state: AppState::Default, 135 | error_state: AppErrorState::None, 136 | settings, 137 | current_page: Page::Init(InitPage::new(components)), 138 | smmdb, 139 | _window_size: WindowSize::M, 140 | settings_button: button::State::new(), 141 | }, 142 | Command::batch(commands), 143 | ) 144 | } 145 | 146 | fn title(&self) -> String { 147 | String::from("SMMDB") 148 | } 149 | 150 | fn update(&mut self, message: Self::Message, _: &mut Clipboard) -> Command { 151 | match message { 152 | Message::Empty => Command::none(), 153 | Message::SetWindowSize(window_size) => { 154 | // TODO listen to application resize somehow 155 | self._window_size = window_size; 156 | Command::none() 157 | } 158 | Message::OpenSave(save) => { 159 | self.state = AppState::Loading; 160 | let display_name = save.get_display_name().clone(); 161 | Command::perform( 162 | async move { 163 | futures::join!( 164 | smmdb_lib::Save::new(save.get_location().clone()), 165 | future::ok::(display_name) 166 | ) 167 | }, 168 | move |res| match res { 169 | (Ok(smmdb_save), Ok(display_name)) => { 170 | Message::LoadSave(Box::new(smmdb_save), display_name) 171 | } 172 | (Err(err), _) => Message::LoadSaveError(err.into()), 173 | _ => todo!(), 174 | }, 175 | ) 176 | } 177 | Message::OpenCustomSave => { 178 | self.state = AppState::Loading; 179 | match nfd::open_pick_folder(None) { 180 | Ok(result) => match result { 181 | Response::Okay(file_path) => { 182 | Command::perform(smmdb_lib::Save::new(file_path.clone()), move |res| { 183 | match res { 184 | Ok(smmdb_save) => Message::LoadSave( 185 | Box::new(smmdb_save), 186 | file_path.clone().to_string_lossy().into(), 187 | ), 188 | Err(err) => Message::LoadSaveError(err.into()), 189 | } 190 | }) 191 | } 192 | Response::OkayMultiple(_files) => { 193 | println!("Not multifile select"); 194 | Command::none() 195 | } 196 | Response::Cancel => { 197 | println!("User canceled"); 198 | Command::none() 199 | } 200 | }, 201 | Err(err) => async move { Message::LoadSaveError(format!("{:?}", err)) }.into(), 202 | } 203 | } 204 | Message::LoadSave(smmdb_save, display_name) => { 205 | self.state = AppState::Default; 206 | self.error_state = AppErrorState::None; 207 | self.current_page = Page::Save(Box::new(SavePage::new( 208 | *smmdb_save.clone(), 209 | display_name, 210 | self.smmdb.get_course_responses(), 211 | ))); 212 | let course_ids: Vec = smmdb_save 213 | .get_own_courses() 214 | .iter() 215 | .filter_map(|c| c.as_ref()) 216 | .map(|course| { 217 | if let CourseEntry::SavedCourse(course) = &**course { 218 | course.get_course().get_smmdb_id() 219 | } else { 220 | None 221 | } 222 | }) 223 | .flatten() 224 | .collect(); 225 | if course_ids.is_empty() { 226 | Command::none() 227 | } else { 228 | async move { Message::FetchSaveCourses(course_ids.clone()) }.into() 229 | } 230 | } 231 | Message::LoadSaveError(err) => { 232 | eprintln!("{}", &err); 233 | self.error_state = 234 | AppErrorState::Some(format!("Could not load save file. Full error:\n{}", err)); 235 | Command::none() 236 | } 237 | Message::FetchSaveCourses(course_ids) => { 238 | let query_params = QueryParams { 239 | limit: 120, 240 | ids: Some(course_ids), 241 | ..QueryParams::default() 242 | }; 243 | let apikey = self.settings.apikey.clone(); 244 | Command::perform(Smmdb::update(query_params, apikey), move |res| match res { 245 | Ok(courses) => Message::SetSaveCourseResponse(courses), 246 | Err(err) => Message::FetchError(err.to_string()), 247 | }) 248 | } 249 | Message::FetchCourses => { 250 | self.state = AppState::Loading; 251 | Command::perform( 252 | Smmdb::update( 253 | self.smmdb.get_query_params().clone(), 254 | self.settings.apikey.clone(), 255 | ), 256 | move |res| match res { 257 | Ok(courses) => Message::SetSmmdbCourses(courses), 258 | Err(err) => Message::FetchError(err.to_string()), 259 | }, 260 | ) 261 | } 262 | Message::FetchSelfCourses => { 263 | if self.settings.apikey.is_some() { 264 | self.state = AppState::Loading; 265 | Command::perform( 266 | Smmdb::update_self( 267 | self.smmdb.get_own_query_params().clone(), 268 | self.settings.apikey.clone(), 269 | ), 270 | move |res| match res { 271 | Ok(courses) => Message::SetSelfSmmdbCourses(courses), 272 | Err(err) => Message::FetchError(err.to_string()), 273 | }, 274 | ) 275 | } else { 276 | Command::none() 277 | } 278 | } 279 | Message::FetchError(err) => { 280 | eprintln!("FetchError: {}", &err); 281 | self.error_state = AppErrorState::Some(err); 282 | Command::none() 283 | } 284 | Message::SetSaveCourseResponse(courses) => { 285 | self.smmdb.set_courses(courses, false); 286 | if let Page::Save(ref mut save_page) = self.current_page { 287 | save_page.set_course_response(self.smmdb.get_course_responses()) 288 | } 289 | self.state = AppState::Default; 290 | Command::none() 291 | } 292 | Message::SetSmmdbCourses(courses) => { 293 | self.state = AppState::Default; 294 | self.error_state = AppErrorState::None; 295 | self.smmdb.set_courses(courses, true); 296 | let course_ids: Vec = 297 | self.smmdb.get_course_panels().keys().cloned().collect(); 298 | 299 | let mut commands = Vec::>::new(); 300 | for id in course_ids { 301 | commands.push(Command::perform( 302 | async move { 303 | futures::join!(Smmdb::fetch_thumbnail(id.clone()), async { id }) 304 | }, 305 | |(thumbnail, id)| { 306 | if let Ok(thumbnail) = thumbnail { 307 | Message::SetSmmdbCourseThumbnail(thumbnail, id) 308 | } else { 309 | // TODO handle error 310 | Message::Empty 311 | } 312 | }, 313 | )); 314 | } 315 | Command::batch(commands) 316 | } 317 | Message::SetSelfSmmdbCourses(courses) => { 318 | self.state = AppState::Default; 319 | self.error_state = AppErrorState::None; 320 | self.smmdb.set_own_courses(courses, true); 321 | let course_ids: Vec = 322 | self.smmdb.get_own_course_panels().keys().cloned().collect(); 323 | 324 | let mut commands = Vec::>::new(); 325 | for id in course_ids { 326 | commands.push(Command::perform( 327 | async move { 328 | futures::join!(Smmdb::fetch_thumbnail(id.clone()), async { id }) 329 | }, 330 | |(thumbnail, id)| { 331 | if let Ok(thumbnail) = thumbnail { 332 | Message::SetSmmdbCourseThumbnail(thumbnail, id) 333 | } else { 334 | // TODO handle error 335 | Message::Empty 336 | } 337 | }, 338 | )); 339 | } 340 | Command::batch(commands) 341 | } 342 | Message::SetSmmdbCourseThumbnail(thumbnail, id) => { 343 | self.smmdb.set_course_panel_thumbnail(&id, thumbnail); 344 | Command::none() 345 | } 346 | Message::SetSmmdbTab(tab) => { 347 | if let Page::Save(ref mut save_page) = self.current_page { 348 | save_page.set_smmdb_tab(tab) 349 | } 350 | Command::none() 351 | } 352 | Message::InitUploadCourse(course) => { 353 | self.state = AppState::UploadSelect(course); 354 | Command::none() 355 | } 356 | Message::UploadCourse(course) => { 357 | self.state = AppState::Loading; 358 | if let Some(apikey) = &self.settings.apikey { 359 | let apikey = apikey.clone(); 360 | let uploaded_course = course.clone(); 361 | Command::perform(Smmdb::upload_course(uploaded_course, apikey), move |res| { 362 | match res { 363 | Ok(res) => { 364 | if !res.succeeded.is_empty() { 365 | Message::UploadSucceeded( 366 | course.clone(), 367 | res.succeeded.get(0).unwrap().id.clone(), 368 | ) 369 | } else { 370 | Message::ResetState 371 | } 372 | } 373 | Err(err) => { 374 | eprintln!("ERR {}", err); 375 | Message::ResetState 376 | } 377 | } 378 | }) 379 | } else { 380 | Command::none() 381 | } 382 | } 383 | Message::UploadSucceeded(course, id) => { 384 | if let Page::Save(ref mut save_page) = self.current_page { 385 | save_page.set_course_response(self.smmdb.get_course_responses()); 386 | let index = course.get_index(); 387 | let mut course = course.get_course().clone(); 388 | course.set_smmdb_id(id.clone()).unwrap(); 389 | let fut = save_page.delete_course(index, self.smmdb.get_course_responses()); 390 | futures::executor::block_on(fut).unwrap(); 391 | let fut = 392 | save_page.add_course(index, course, self.smmdb.get_course_responses()); 393 | futures::executor::block_on(fut).unwrap(); 394 | } 395 | Command::batch(vec![ 396 | async { Message::FetchSaveCourses(vec![id]) }.into(), 397 | async { Message::FetchCourses }.into(), 398 | async { Message::FetchSelfCourses }.into(), 399 | ]) 400 | } 401 | Message::InitSwapCourse(index) => { 402 | self.state = AppState::SwapSelect(index); 403 | Command::none() 404 | } 405 | Message::SwapCourse(first, second) => { 406 | self.state = AppState::Loading; 407 | 408 | match self.current_page { 409 | Page::Save(ref mut save_page) => { 410 | let fut = save_page.swap_courses( 411 | first as u8, 412 | second as u8, 413 | self.smmdb.get_course_responses(), 414 | ); 415 | futures::executor::block_on(fut).unwrap(); 416 | // TODO find better way than block_on 417 | async { Message::ResetState }.into() 418 | } 419 | _ => Command::none(), 420 | } 421 | } 422 | Message::InitDownloadCourse(index) => { 423 | self.state = AppState::DownloadSelect(index); 424 | Command::none() 425 | } 426 | Message::DownloadCourse(save_index, smmdb_id) => { 427 | self.state = AppState::Downloading { 428 | save_index, 429 | smmdb_id, 430 | progress: 0., 431 | }; 432 | Command::none() 433 | } 434 | Message::DownloadProgressed(message) => { 435 | if let AppState::Downloading { 436 | save_index, 437 | progress, 438 | .. 439 | } = &mut self.state 440 | { 441 | match message { 442 | Progress::Started => { 443 | *progress = 0.; 444 | } 445 | Progress::Advanced(percentage) => { 446 | *progress = percentage; 447 | } 448 | Progress::Finished(data) => { 449 | match self.current_page { 450 | Page::Save(ref mut save_page) => { 451 | let course: smmdb_lib::Course2 = data.try_into().unwrap(); 452 | let fut = save_page.add_course( 453 | *save_index as u8, 454 | course, 455 | self.smmdb.get_course_responses(), 456 | ); 457 | futures::executor::block_on(fut).unwrap(); 458 | // TODO find better way than block_on 459 | return async { Message::ResetState }.into(); 460 | } 461 | _ => { 462 | todo!() 463 | } 464 | } 465 | } 466 | Progress::Errored => { 467 | // TODO 468 | } 469 | } 470 | }; 471 | Command::none() 472 | } 473 | Message::InitDeleteCourse(index) => { 474 | self.state = AppState::DeleteSelect(index); 475 | Command::none() 476 | } 477 | Message::DeleteCourse(index) => { 478 | self.state = AppState::Loading; 479 | 480 | match self.current_page { 481 | Page::Save(ref mut save_page) => { 482 | let fut = 483 | save_page.delete_course(index as u8, self.smmdb.get_course_responses()); 484 | futures::executor::block_on(fut).unwrap(); 485 | // TODO find better way than block_on 486 | async { Message::ResetState }.into() 487 | } 488 | _ => Command::none(), 489 | } 490 | } 491 | Message::InitDeleteSmmdbCourse(id) => { 492 | self.state = AppState::DeleteSmmdbSelect(id); 493 | Command::none() 494 | } 495 | Message::DeleteSmmdbCourse(id) => { 496 | self.state = AppState::Loading; 497 | 498 | if let Some(apikey) = &self.settings.apikey { 499 | let apikey = apikey.clone(); 500 | Command::perform(Smmdb::delete_course(id.clone(), apikey), move |res| { 501 | if let Err(err) = res { 502 | eprintln!("{:?}", err); 503 | } 504 | Message::ReloadAfterDelete(id.clone()) 505 | }) 506 | } else { 507 | Command::none() 508 | } 509 | } 510 | Message::ReloadAfterDelete(id) => { 511 | self.state = AppState::Default; 512 | self.error_state = AppErrorState::None; 513 | 514 | self.smmdb.delete_course_response(id); 515 | if let Page::Save(ref mut save_page) = self.current_page { 516 | save_page.set_course_response(self.smmdb.get_course_responses()) 517 | } 518 | 519 | Command::batch(vec![ 520 | async { Message::FetchCourses }.into(), 521 | async { Message::FetchSelfCourses }.into(), 522 | ]) 523 | } 524 | Message::TitleChanged(title) => { 525 | self.smmdb.set_title(title); 526 | Command::none() 527 | } 528 | Message::UploaderChanged(uploader) => { 529 | self.smmdb.set_uploader(uploader); 530 | Command::none() 531 | } 532 | Message::DifficultyChanged(difficulty) => { 533 | self.smmdb.set_difficulty(difficulty); 534 | Command::none() 535 | } 536 | Message::SortChanged(sort) => { 537 | self.smmdb.set_sort(sort); 538 | Command::none() 539 | } 540 | Message::ApplyFilters => { 541 | self.state = AppState::Loading; 542 | self.smmdb.reset_pagination(); 543 | Command::perform( 544 | Smmdb::update( 545 | self.smmdb.get_query_params().clone(), 546 | self.settings.apikey.clone(), 547 | ), 548 | move |res| match res { 549 | Ok(courses) => Message::SetSmmdbCourses(courses), 550 | Err(err) => Message::FetchError(err.to_string()), 551 | }, 552 | ) 553 | } 554 | Message::PaginateForward => { 555 | self.state = AppState::Loading; 556 | self.smmdb.paginate_forward(); 557 | Command::perform( 558 | Smmdb::update( 559 | self.smmdb.get_query_params().clone(), 560 | self.settings.apikey.clone(), 561 | ), 562 | move |res| match res { 563 | Ok(courses) => Message::SetSmmdbCourses(courses), 564 | Err(err) => Message::FetchError(err.to_string()), 565 | }, 566 | ) 567 | } 568 | Message::PaginateBackward => { 569 | self.state = AppState::Loading; 570 | self.smmdb.paginate_backward(); 571 | Command::perform( 572 | Smmdb::update( 573 | self.smmdb.get_query_params().clone(), 574 | self.settings.apikey.clone(), 575 | ), 576 | move |res| match res { 577 | Ok(courses) => Message::SetSmmdbCourses(courses), 578 | Err(err) => Message::FetchError(err.to_string()), 579 | }, 580 | ) 581 | } 582 | Message::PaginateSelfForward => { 583 | self.state = AppState::Loading; 584 | self.smmdb.self_paginate_forward(); 585 | Command::perform( 586 | Smmdb::update_self( 587 | self.smmdb.get_own_query_params().clone(), 588 | self.settings.apikey.clone(), 589 | ), 590 | move |res| match res { 591 | Ok(courses) => Message::SetSelfSmmdbCourses(courses), 592 | Err(err) => Message::FetchError(err.to_string()), 593 | }, 594 | ) 595 | } 596 | Message::PaginateSelfBackward => { 597 | self.state = AppState::Loading; 598 | self.smmdb.self_paginate_backward(); 599 | Command::perform( 600 | Smmdb::update_self( 601 | self.smmdb.get_own_query_params().clone(), 602 | self.settings.apikey.clone(), 603 | ), 604 | move |res| match res { 605 | Ok(courses) => Message::SetSelfSmmdbCourses(courses), 606 | Err(err) => Message::FetchError(err.to_string()), 607 | }, 608 | ) 609 | } 610 | Message::UpvoteCourse(course_id) => { 611 | if let Some(apikey) = self.settings.apikey.clone() { 612 | Command::perform( 613 | Smmdb::vote(course_id.clone(), 1, apikey), 614 | move |res| match res { 615 | Ok(()) => Message::SetVoteCourse(course_id.clone(), 1), 616 | Err(err) => Message::FetchError(err), 617 | }, 618 | ) 619 | } else { 620 | Command::none() 621 | } 622 | } 623 | Message::DownvoteCourse(course_id) => { 624 | if let Some(apikey) = self.settings.apikey.clone() { 625 | Command::perform(Smmdb::vote(course_id.clone(), -1, apikey), move |res| { 626 | match res { 627 | Ok(()) => Message::SetVoteCourse(course_id.clone(), -1), 628 | Err(err) => Message::FetchError(err), 629 | } 630 | }) 631 | } else { 632 | Command::none() 633 | } 634 | } 635 | Message::ResetCourseVote(course_id) => { 636 | if let Some(apikey) = self.settings.apikey.clone() { 637 | Command::perform( 638 | Smmdb::vote(course_id.clone(), 0, apikey), 639 | move |res| match res { 640 | Ok(()) => Message::SetVoteCourse(course_id.clone(), 0), 641 | Err(err) => Message::FetchError(err), 642 | }, 643 | ) 644 | } else { 645 | Command::none() 646 | } 647 | } 648 | Message::SetVoteCourse(course_id, value) => { 649 | self.smmdb.set_own_vote(course_id, value); 650 | if let Page::Save(ref mut save_page) = self.current_page { 651 | save_page.set_course_response(self.smmdb.get_course_responses()) 652 | } 653 | Command::none() 654 | } 655 | Message::OpenSettings => { 656 | if let Page::Settings(_) = self.current_page { 657 | } else { 658 | self.current_page = Page::Settings(SettingsPage::new( 659 | self.settings.clone(), 660 | self.current_page.clone(), 661 | )); 662 | } 663 | Command::none() 664 | } 665 | Message::TrySaveSettings(settings) => { 666 | settings.save().unwrap(); 667 | match &settings.apikey { 668 | Some(apikey) => { 669 | Command::perform(Smmdb::try_sign_in(apikey.clone()), move |res| match res { 670 | Ok(user) => Message::SaveSettings((settings.clone(), Some(user))), 671 | Err(err) => Message::RejectSettings(err), 672 | }) 673 | } 674 | None => async move { Message::SaveSettings((settings.clone(), None)) }.into(), 675 | } 676 | } 677 | Message::SaveSettings((settings, user)) => { 678 | settings.save().unwrap(); 679 | self.settings = settings; 680 | self.smmdb.set_user(user); 681 | if let Page::Settings(ref mut settings_page) = self.current_page { 682 | self.current_page = settings_page.get_prev_page() 683 | } 684 | self.error_state = AppErrorState::None; 685 | Command::batch(vec![ 686 | async { Message::FetchCourses }.into(), 687 | async { Message::FetchSelfCourses }.into(), 688 | ]) 689 | } 690 | Message::RejectSettings(err) => { 691 | self.error_state = AppErrorState::Some(err); 692 | Command::none() 693 | } 694 | Message::CloseSettings => { 695 | if let Page::Settings(ref mut settings_page) = self.current_page { 696 | self.current_page = settings_page.get_prev_page() 697 | } 698 | self.error_state = AppErrorState::None; 699 | Command::none() 700 | } 701 | Message::ChangeApiKey(apikey) => { 702 | if let Page::Settings(ref mut settings_page) = self.current_page { 703 | settings_page.set_apikey(apikey); 704 | } 705 | Command::none() 706 | } 707 | Message::ResetApiKey => { 708 | if let Page::Settings(ref mut settings_page) = self.current_page { 709 | settings_page.unset_apikey(); 710 | } 711 | Command::none() 712 | } 713 | Message::ResetState => { 714 | self.state = AppState::Default; 715 | self.error_state = AppErrorState::None; 716 | Command::none() 717 | } 718 | } 719 | } 720 | 721 | fn subscription(&self) -> Subscription { 722 | match &self.state { 723 | AppState::UploadSelect(_) 724 | | AppState::SwapSelect(_) 725 | | AppState::DownloadSelect(_) 726 | | AppState::DeleteSelect(_) 727 | | AppState::DeleteSmmdbSelect(_) => subscription::events().map(|event| match event { 728 | Event::Keyboard(keyboard::Event::KeyReleased { 729 | key_code: keyboard::KeyCode::Escape, 730 | modifiers: _, 731 | }) => Message::ResetState, 732 | _ => Message::Empty, 733 | }), 734 | AppState::Downloading { smmdb_id, .. } => { 735 | Smmdb::download_course(smmdb_id.clone()).map(Message::DownloadProgressed) 736 | } 737 | AppState::Default | AppState::Loading => Subscription::none(), 738 | } 739 | } 740 | 741 | fn view(&mut self) -> Element { 742 | Container::new( 743 | Column::new() 744 | .push( 745 | Row::new() 746 | .push(Space::with_width(Length::Fill)) 747 | .push( 748 | Button::new( 749 | &mut self.settings_button, 750 | icon::SETTINGS 751 | .clone() 752 | .width(Length::Units(24)) 753 | .height(Length::Units(24)), 754 | ) 755 | .style(DefaultButtonStyle) 756 | .on_press(Message::OpenSettings), 757 | ) 758 | .padding(12), 759 | ) 760 | .push(match &mut self.current_page { 761 | Page::Init(init_page) => init_page.view(&self.state, &self.error_state), 762 | Page::Save(save_page) => save_page.view(&self.state, &mut self.smmdb), 763 | Page::Settings(settings_page) => settings_page.view(&self.error_state), 764 | }), 765 | ) 766 | .style(AppStyle) 767 | .width(Length::Fill) 768 | .height(Length::Fill) 769 | .into() 770 | } 771 | } 772 | 773 | struct AppStyle; 774 | 775 | impl container::StyleSheet for AppStyle { 776 | fn style(&self) -> container::Style { 777 | container::Style { 778 | background: Some(Background::Color(COLOR_YELLOW)), 779 | ..container::Style::default() 780 | } 781 | } 782 | } 783 | -------------------------------------------------------------------------------- /src/components/course_panel.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | components::VotingPanel, 3 | font::*, 4 | icon, 5 | smmdb::{Course2Response, SmmdbUser}, 6 | styles::*, 7 | AppState, Message, 8 | }; 9 | 10 | use iced::{ 11 | button, container, image, Align, Button, Color, Column, Container, Element, Image, Length, 12 | ProgressBar, Row, Space, Text, 13 | }; 14 | use smmdb_lib::CourseEntry; 15 | 16 | #[derive(Clone, Debug)] 17 | pub struct CoursePanel { 18 | voting_panel: VotingPanel, 19 | panel_state: button::State, 20 | add_state: button::State, 21 | upload_state: button::State, 22 | swap_state: button::State, 23 | delete_state: button::State, 24 | delete_confirm_state: button::State, 25 | delete_cancel_state: button::State, 26 | course: Option>, 27 | course_response: Option, 28 | } 29 | 30 | impl CoursePanel { 31 | pub fn new( 32 | course: Option>, 33 | course_response: Option, 34 | ) -> CoursePanel { 35 | CoursePanel { 36 | voting_panel: VotingPanel::new(), 37 | panel_state: button::State::new(), 38 | add_state: button::State::new(), 39 | upload_state: button::State::new(), 40 | swap_state: button::State::new(), 41 | delete_state: button::State::new(), 42 | delete_confirm_state: button::State::new(), 43 | delete_cancel_state: button::State::new(), 44 | course, 45 | course_response, 46 | } 47 | } 48 | 49 | pub fn get_smmdb_id(&self) -> Option { 50 | if let Some(course) = &self.course { 51 | if let CourseEntry::SavedCourse(course) = &**course { 52 | course.get_course().get_smmdb_id() 53 | } else { 54 | None 55 | } 56 | } else { 57 | None 58 | } 59 | } 60 | 61 | pub fn set_response(&mut self, course_response: Course2Response) { 62 | self.course_response = Some(course_response); 63 | } 64 | 65 | pub fn delete_response(&mut self) { 66 | self.course_response = None; 67 | } 68 | 69 | pub fn view( 70 | &mut self, 71 | state: &AppState, 72 | index: usize, 73 | smmdb_user: Option<&SmmdbUser>, 74 | ) -> impl Into> { 75 | let content: Element = if let Some(course) = &self.course { 76 | match &**course { 77 | CourseEntry::SavedCourse(course) => { 78 | let course = course.get_course(); 79 | let course_header = course.get_course().get_header(); 80 | 81 | let mut inner_content = Row::new(); 82 | 83 | if let Some(course_response) = &self.course_response { 84 | let voting_content = self.voting_panel.view( 85 | course_response.get_id().clone(), 86 | course_response.get_votes(), 87 | course_response.get_own_vote(), 88 | smmdb_user, 89 | ); 90 | inner_content = inner_content 91 | .push(voting_content) 92 | .push(Space::with_width(Length::Units(10))); 93 | } 94 | 95 | inner_content = inner_content 96 | .push( 97 | Container::new(Image::new(image::Handle::from_memory( 98 | course.get_course_thumb().unwrap().clone().take_jpeg(), 99 | ))) 100 | .max_width(240), 101 | ) 102 | .push(Space::with_width(Length::Units(10))) 103 | .push( 104 | Text::new(course_header.get_description()) 105 | .size(15) 106 | .width(Length::Fill), 107 | ) 108 | .align_items(Align::Center); 109 | 110 | let mut content = Column::new() 111 | .push(Text::new(course_header.get_title()).size(24)) 112 | .push(Space::with_height(Length::Units(10))) 113 | .push(inner_content) 114 | .width(Length::Shrink); 115 | 116 | content = match state { 117 | AppState::DeleteSelect(idx) if *idx == index => content 118 | .push(Space::with_height(Length::Units(18))) 119 | .push( 120 | Text::new("Do you really want to delete this course?") 121 | .size(16) 122 | .font(HELVETICA_BOLD), 123 | ) 124 | .push( 125 | Row::new() 126 | .push(Space::with_width(Length::Fill)) 127 | .push( 128 | Button::new( 129 | &mut self.delete_cancel_state, 130 | Text::new("Cancel").size(20).font(HELVETICA_BOLD), 131 | ) 132 | .padding(BUTTON_PADDING) 133 | .style(DefaultButtonStyle) 134 | .on_press(Message::ResetState), 135 | ) 136 | .push(Space::with_width(Length::Units(16))) 137 | .push( 138 | Button::new( 139 | &mut self.delete_confirm_state, 140 | Text::new("Delete").size(20).font(HELVETICA_BOLD), 141 | ) 142 | .padding(BUTTON_PADDING) 143 | .style(DeleteButtonStyle) 144 | .on_press(Message::DeleteCourse(index)), 145 | ), 146 | ), 147 | AppState::UploadSelect(upload_course) 148 | if upload_course.get_index() == index as u8 => 149 | { 150 | content 151 | .push(Space::with_height(Length::Units(18))) 152 | .push( 153 | Text::new("Do you really want to upload this course?") 154 | .size(16) 155 | .font(HELVETICA_BOLD), 156 | ) 157 | .push( 158 | Row::new() 159 | .push(Space::with_width(Length::Fill)) 160 | .push( 161 | Button::new( 162 | &mut self.delete_cancel_state, 163 | Text::new("Cancel").size(20).font(HELVETICA_BOLD), 164 | ) 165 | .padding(BUTTON_PADDING) 166 | .style(DefaultButtonStyle) 167 | .on_press(Message::ResetState), 168 | ) 169 | .push(Space::with_width(Length::Units(16))) 170 | .push( 171 | Button::new( 172 | &mut self.delete_confirm_state, 173 | Text::new("Upload").size(20).font(HELVETICA_BOLD), 174 | ) 175 | .padding(BUTTON_PADDING) 176 | .style(UploadButtonStyle) 177 | .on_press(Message::UploadCourse(upload_course.clone())), 178 | ), 179 | ) 180 | } 181 | _ => content, 182 | }; 183 | 184 | content.into() 185 | } 186 | CourseEntry::CorruptedCourse(_) => { 187 | todo!() 188 | } 189 | } 190 | } else { 191 | let empty_text = Text::new("empty").size(18).width(Length::Shrink); 192 | let content: Element = if let AppState::Downloading { 193 | save_index, 194 | progress, 195 | .. 196 | } = state 197 | { 198 | if *save_index == index { 199 | ProgressBar::new(0.0..=100.0, *progress).into() 200 | } else { 201 | empty_text.into() 202 | } 203 | } else { 204 | empty_text.into() 205 | }; 206 | 207 | Container::new(content) 208 | .width(Length::Fill) 209 | .height(Length::Units(80)) 210 | .center_x() 211 | .center_y() 212 | .into() 213 | }; 214 | 215 | let panel: Element = match state { 216 | AppState::SwapSelect(idx) => Button::new(&mut self.panel_state, content) 217 | .style(CoursePanelButtonStyle(state.clone(), index)) 218 | .padding(12) 219 | .width(Length::Fill) 220 | .on_press(Message::SwapCourse(*idx, index)) 221 | .into(), 222 | _ => Container::new(content) 223 | .style(CoursePanelStyle(state.clone(), index)) 224 | .padding(12) 225 | .width(Length::Fill) 226 | .into(), 227 | }; 228 | 229 | let mut actions = Column::new(); 230 | if let Some(course) = &self.course { 231 | match &**course { 232 | CourseEntry::SavedCourse(course) => { 233 | let mut swap_button = Button::new( 234 | &mut self.swap_state, 235 | icon::SORT 236 | .clone() 237 | .width(Length::Units(24)) 238 | .height(Length::Units(24)), 239 | ) 240 | .style(SwapButtonStyle(state.clone(), index)); 241 | swap_button = match state { 242 | AppState::SwapSelect(idx) => { 243 | if *idx == index { 244 | swap_button.on_press(Message::ResetState) 245 | } else { 246 | swap_button.on_press(Message::InitSwapCourse(index)) 247 | } 248 | } 249 | AppState::Loading | AppState::Downloading { .. } => swap_button, 250 | _ => swap_button.on_press(Message::InitSwapCourse(index)), 251 | }; 252 | 253 | let mut delete_button = Button::new( 254 | &mut self.delete_state, 255 | icon::DELETE 256 | .clone() 257 | .width(Length::Units(24)) 258 | .height(Length::Units(24)), 259 | ) 260 | .style(DeleteButtonStyle); 261 | delete_button = match state { 262 | AppState::DeleteSelect(idx) => { 263 | if *idx == index { 264 | delete_button.on_press(Message::ResetState) 265 | } else { 266 | delete_button.on_press(Message::InitDeleteCourse(index)) 267 | } 268 | } 269 | AppState::Loading | AppState::Downloading { .. } => delete_button, 270 | _ => delete_button.on_press(Message::InitDeleteCourse(index)), 271 | }; 272 | 273 | if smmdb_user.is_some() 274 | && self.course_response.is_none() 275 | && state != &AppState::Loading 276 | { 277 | let upload_button = Button::new( 278 | &mut self.upload_state, 279 | icon::UPLOAD 280 | .clone() 281 | .width(Length::Units(24)) 282 | .height(Length::Units(24)), 283 | ) 284 | .style(DefaultButtonStyle) 285 | .on_press(Message::InitUploadCourse(course.clone())); 286 | actions = actions 287 | .push(upload_button) 288 | .push(Space::with_height(Length::Units(10))); 289 | } 290 | 291 | actions = actions 292 | .push(swap_button) 293 | .push(Space::with_height(Length::Units(10))) 294 | .push(delete_button); 295 | } 296 | CourseEntry::CorruptedCourse(_) => { 297 | todo!(); 298 | } 299 | } 300 | } else { 301 | let mut download_button = Button::new( 302 | &mut self.add_state, 303 | icon::ADD 304 | .clone() 305 | .width(Length::Units(24)) 306 | .height(Length::Units(24)), 307 | ) 308 | .style(DownloadButtonStyle(state.clone(), index)); 309 | download_button = match state { 310 | AppState::DownloadSelect(idx) => { 311 | if *idx == index { 312 | download_button.on_press(Message::ResetState) 313 | } else { 314 | download_button.on_press(Message::InitDownloadCourse(index)) 315 | } 316 | } 317 | AppState::Loading | AppState::Downloading { .. } => download_button, 318 | _ => download_button.on_press(Message::InitDownloadCourse(index)), 319 | }; 320 | 321 | actions = actions.push(download_button); 322 | } 323 | 324 | Row::new() 325 | .align_items(Align::Center) 326 | .push(panel) 327 | .push(Space::with_width(Length::Units(10))) 328 | .push(actions) 329 | } 330 | } 331 | 332 | struct CoursePanelButtonStyle(AppState, usize); 333 | 334 | impl button::StyleSheet for CoursePanelButtonStyle { 335 | fn active(&self) -> button::Style { 336 | button::Style { 337 | text_color: Color::BLACK, 338 | background: match self.0 { 339 | AppState::SwapSelect(index) => { 340 | if self.1 != index { 341 | Some(PANEL_SELECT_ACTIVE) 342 | } else { 343 | Some(PANEL_ACTIVE) 344 | } 345 | } 346 | _ => Some(PANEL_ACTIVE), 347 | }, 348 | border_radius: 8., 349 | border_width: 0., 350 | ..button::Style::default() 351 | } 352 | } 353 | 354 | fn hovered(&self) -> button::Style { 355 | button::Style { 356 | text_color: Color::BLACK, 357 | background: match self.0 { 358 | AppState::SwapSelect(index) => { 359 | if self.1 != index { 360 | Some(PANEL_SELECT_HOVER) 361 | } else { 362 | Some(PANEL_ACTIVE) 363 | } 364 | } 365 | AppState::DownloadSelect(index) => { 366 | if self.1 == index { 367 | Some(PANEL_DOWNLOAD) 368 | } else { 369 | Some(PANEL_ACTIVE) 370 | } 371 | } 372 | _ => Some(PANEL_ACTIVE), 373 | }, 374 | border_radius: 8., 375 | border_width: 0., 376 | ..button::Style::default() 377 | } 378 | } 379 | } 380 | 381 | struct CoursePanelStyle(AppState, usize); 382 | 383 | impl container::StyleSheet for CoursePanelStyle { 384 | fn style(&self) -> container::Style { 385 | container::Style { 386 | background: Some(PANEL_ACTIVE), 387 | border_radius: 8., 388 | border_width: 0., 389 | ..container::Style::default() 390 | } 391 | } 392 | } 393 | 394 | struct UploadButtonStyle; 395 | 396 | impl button::StyleSheet for UploadButtonStyle { 397 | fn active(&self) -> button::Style { 398 | button::Style { 399 | background: Some(BUTTON_ACTIVE), 400 | border_radius: 4., 401 | border_width: 0., 402 | ..button::Style::default() 403 | } 404 | } 405 | 406 | fn hovered(&self) -> button::Style { 407 | button::Style { 408 | text_color: Color::WHITE, 409 | background: Some(BUTTON_CONFIRM), 410 | border_radius: 4., 411 | ..button::Style::default() 412 | } 413 | } 414 | 415 | fn disabled(&self) -> button::Style { 416 | button::Style { 417 | background: Some(BUTTON_DISABLED), 418 | border_radius: 4., 419 | ..button::Style::default() 420 | } 421 | } 422 | } 423 | 424 | struct SwapButtonStyle(AppState, usize); 425 | 426 | impl button::StyleSheet for SwapButtonStyle { 427 | fn active(&self) -> button::Style { 428 | button::Style { 429 | background: match self.0 { 430 | AppState::SwapSelect(index) => { 431 | if self.1 == index { 432 | Some(BUTTON_SELECT_ACTIVE) 433 | } else { 434 | Some(BUTTON_ACTIVE) 435 | } 436 | } 437 | _ => Some(BUTTON_ACTIVE), 438 | }, 439 | border_radius: 4., 440 | ..button::Style::default() 441 | } 442 | } 443 | 444 | fn hovered(&self) -> button::Style { 445 | button::Style { 446 | text_color: Color::WHITE, 447 | background: match self.0 { 448 | AppState::SwapSelect(index) => { 449 | if self.1 == index { 450 | Some(BUTTON_SELECT_CANCEL) 451 | } else { 452 | Some(BUTTON_HOVER) 453 | } 454 | } 455 | _ => Some(BUTTON_HOVER), 456 | }, 457 | border_radius: 4., 458 | ..button::Style::default() 459 | } 460 | } 461 | 462 | fn disabled(&self) -> button::Style { 463 | button::Style { 464 | background: Some(BUTTON_DISABLED), 465 | border_radius: 4., 466 | ..button::Style::default() 467 | } 468 | } 469 | } 470 | 471 | struct DownloadButtonStyle(AppState, usize); 472 | 473 | impl button::StyleSheet for DownloadButtonStyle { 474 | fn active(&self) -> button::Style { 475 | button::Style { 476 | background: match self.0 { 477 | AppState::DownloadSelect(index) => { 478 | if self.1 == index { 479 | Some(BUTTON_SELECT_ACTIVE) 480 | } else { 481 | Some(BUTTON_ACTIVE) 482 | } 483 | } 484 | _ => Some(BUTTON_ACTIVE), 485 | }, 486 | border_radius: 4., 487 | border_width: 0., 488 | ..button::Style::default() 489 | } 490 | } 491 | 492 | fn hovered(&self) -> button::Style { 493 | button::Style { 494 | text_color: Color::WHITE, 495 | background: match self.0 { 496 | AppState::DownloadSelect(index) => { 497 | if self.1 == index { 498 | Some(BUTTON_SELECT_CANCEL) 499 | } else { 500 | Some(BUTTON_HOVER) 501 | } 502 | } 503 | _ => Some(BUTTON_HOVER), 504 | }, 505 | border_radius: 4., 506 | ..button::Style::default() 507 | } 508 | } 509 | 510 | fn disabled(&self) -> button::Style { 511 | button::Style { 512 | background: Some(BUTTON_DISABLED), 513 | border_radius: 4., 514 | ..button::Style::default() 515 | } 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /src/components/mod.rs: -------------------------------------------------------------------------------- 1 | mod course_panel; 2 | mod save_button; 3 | mod smmdb_course_panel; 4 | mod voting_panel; 5 | 6 | pub use course_panel::*; 7 | pub use save_button::*; 8 | pub use smmdb_course_panel::*; 9 | pub use voting_panel::*; 10 | -------------------------------------------------------------------------------- /src/components/save_button.rs: -------------------------------------------------------------------------------- 1 | use crate::{styles::*, AppState, EmuSave, EmuType, Message}; 2 | 3 | use iced::{button, Button, Element, Text}; 4 | use std::path::PathBuf; 5 | 6 | #[derive(Clone, Debug)] 7 | pub struct SaveButton { 8 | display_name: String, 9 | state: button::State, 10 | save: EmuSave, 11 | } 12 | 13 | impl SaveButton { 14 | pub fn new(display_name: String, location: PathBuf, emu_type: EmuType) -> SaveButton { 15 | SaveButton { 16 | display_name: display_name.clone(), 17 | state: button::State::new(), 18 | save: EmuSave::new(display_name, location, emu_type), 19 | } 20 | } 21 | 22 | pub fn view(&mut self, state: &AppState) -> impl Into> { 23 | let mut save_button = Button::new(&mut self.state, Text::new(&self.display_name)) 24 | .padding(BUTTON_PADDING) 25 | .style(SaveButtonStyle); 26 | save_button = match state { 27 | AppState::Loading => save_button, 28 | _ => save_button.on_press(Message::OpenSave(self.save.clone())), 29 | }; 30 | save_button 31 | } 32 | } 33 | 34 | struct SaveButtonStyle; 35 | 36 | impl button::StyleSheet for SaveButtonStyle { 37 | fn active(&self) -> button::Style { 38 | button::Style { 39 | background: Some(BUTTON_ACTIVE), 40 | border_radius: 4., 41 | ..button::Style::default() 42 | } 43 | } 44 | 45 | fn hovered(&self) -> button::Style { 46 | button::Style { 47 | background: Some(BUTTON_HOVER), 48 | border_radius: 4., 49 | ..button::Style::default() 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/smmdb_course_panel.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | components::VotingPanel, 3 | font::*, 4 | icon, 5 | smmdb::{Course2Response, Difficulty, SmmdbUser}, 6 | styles::*, 7 | AppState, Message, 8 | }; 9 | 10 | use iced::{ 11 | button, container, Align, Background, Button, Color, Column, Container, Element, Image, Length, 12 | Row, Space, Text, 13 | }; 14 | use iced_native::widget::image::Handle; 15 | 16 | #[derive(Debug)] 17 | pub struct SmmdbCoursePanel { 18 | voting_panel: VotingPanel, 19 | panel_state: button::State, 20 | upvote_state: button::State, 21 | downvote_state: button::State, 22 | delete_state: button::State, 23 | delete_confirm_state: button::State, 24 | delete_cancel_state: button::State, 25 | course: Course2Response, 26 | thumbnail: Option>, 27 | } 28 | 29 | impl SmmdbCoursePanel { 30 | pub fn new(course: Course2Response) -> SmmdbCoursePanel { 31 | SmmdbCoursePanel { 32 | voting_panel: VotingPanel::new(), 33 | panel_state: button::State::new(), 34 | upvote_state: button::State::new(), 35 | downvote_state: button::State::new(), 36 | delete_state: button::State::new(), 37 | delete_confirm_state: button::State::new(), 38 | delete_cancel_state: button::State::new(), 39 | course, 40 | thumbnail: None, 41 | } 42 | } 43 | 44 | pub fn set_own_vote(&mut self, value: i32) { 45 | self.course.set_own_vote(value); 46 | } 47 | 48 | pub fn view( 49 | &mut self, 50 | state: &AppState, 51 | smmdb_user: Option<&SmmdbUser>, 52 | ) -> impl Into> { 53 | let course = self.course.get_course(); 54 | let course_header = course.get_header(); 55 | let course_id = self.course.get_id(); 56 | 57 | let thumbnail: Element = if let Some(thumbnail) = &self.thumbnail { 58 | Image::new(Handle::from_memory(thumbnail.clone())) 59 | .width(Length::Units(240)) 60 | .height(Length::Units(135)) 61 | .into() 62 | } else { 63 | Space::new(Length::Units(240), Length::Units(135)).into() 64 | }; 65 | 66 | let difficulty: Element = match self.course.get_difficulty() { 67 | Some(difficulty) => { 68 | let row = Row::new() 69 | .align_items(Align::End) 70 | .push(Text::new("Difficulty:").size(15)) 71 | .push(Space::with_width(Length::Units(4))); 72 | match difficulty { 73 | Difficulty::Unset => row, 74 | Difficulty::Easy => row 75 | .push(Image::new(icon::EASY.clone())) 76 | .push(Text::new("Easy").size(15)), 77 | Difficulty::Normal => row 78 | .push(Image::new(icon::NORMAL.clone())) 79 | .push(Text::new("Normal").size(15)), 80 | Difficulty::Expert => row 81 | .push(Image::new(icon::EXPERT.clone())) 82 | .push(Text::new("Expert").size(15)), 83 | Difficulty::SuperExpert => row 84 | .push(Image::new(icon::SUPER_EXPERT.clone())) 85 | .push(Text::new("Super Expert").size(15)), 86 | } 87 | .into() 88 | } 89 | None => Space::with_height(Length::Shrink).into(), 90 | }; 91 | 92 | let voting_content = self.voting_panel.view( 93 | self.course.get_id().clone(), 94 | self.course.get_votes(), 95 | self.course.get_own_vote(), 96 | smmdb_user, 97 | ); 98 | 99 | let inner_content = Row::new() 100 | .push(voting_content) 101 | .push(Space::with_width(Length::Units(10))) 102 | .push(Container::new(thumbnail).style(ThumbnailStyle)) 103 | .push(Space::with_width(Length::Units(10))) 104 | .push( 105 | Column::new() 106 | .push(Text::new(course_header.get_description()).size(15)) 107 | .push(Space::with_height(Length::Units(LIST_SPACING))) 108 | .push(difficulty), 109 | ) 110 | .align_items(Align::Center); 111 | 112 | let mut content = Column::new() 113 | .push(Text::new(course_header.get_title()).size(24)) 114 | .push(Space::with_height(Length::Units(10))) 115 | .push(inner_content); 116 | 117 | content = match state { 118 | AppState::DeleteSmmdbSelect(id) if id == course_id => content 119 | .push(Space::with_height(Length::Units(18))) 120 | .push( 121 | Text::new("Do you really want to delete this course?") 122 | .size(16) 123 | .font(HELVETICA_BOLD), 124 | ) 125 | .push( 126 | Row::new() 127 | .push(Space::with_width(Length::Fill)) 128 | .push( 129 | Button::new( 130 | &mut self.delete_cancel_state, 131 | Text::new("Cancel").size(20).font(HELVETICA_BOLD), 132 | ) 133 | .padding(BUTTON_PADDING) 134 | .style(DefaultButtonStyle) 135 | .on_press(Message::ResetState), 136 | ) 137 | .push(Space::with_width(Length::Units(16))) 138 | .push( 139 | Button::new( 140 | &mut self.delete_confirm_state, 141 | Text::new("Delete").size(20).font(HELVETICA_BOLD), 142 | ) 143 | .padding(BUTTON_PADDING) 144 | .style(DeleteButtonStyle) 145 | .on_press(Message::DeleteSmmdbCourse(course_id.clone())), 146 | ), 147 | ), 148 | _ => content, 149 | }; 150 | 151 | let panel: Element = match state { 152 | AppState::DownloadSelect(index) => Button::new(&mut self.panel_state, content) 153 | .style(SmmdbCoursePanelButtonStyle(state.clone())) 154 | .padding(12) 155 | .width(Length::Fill) 156 | .on_press(Message::DownloadCourse( 157 | *index, 158 | self.course.get_id().clone(), 159 | )) 160 | .into(), 161 | _ => Container::new(content) 162 | .style(SmmdbCoursePanelStyle) 163 | .padding(12) 164 | .width(Length::Fill) 165 | .into(), 166 | }; 167 | 168 | let mut actions = Column::new(); 169 | 170 | if let Some(smmdb_user) = smmdb_user { 171 | if self.course.get_owner() == &smmdb_user.id { 172 | let mut delete_button = Button::new( 173 | &mut self.delete_state, 174 | icon::DELETE 175 | .clone() 176 | .width(Length::Units(24)) 177 | .height(Length::Units(24)), 178 | ) 179 | .style(DeleteButtonStyle); 180 | delete_button = match state { 181 | AppState::DeleteSmmdbSelect(id) => { 182 | if id == course_id { 183 | delete_button.on_press(Message::ResetState) 184 | } else { 185 | delete_button 186 | .on_press(Message::InitDeleteSmmdbCourse(course_id.clone())) 187 | } 188 | } 189 | AppState::Loading | AppState::Downloading { .. } => delete_button, 190 | _ => delete_button.on_press(Message::InitDeleteSmmdbCourse(course_id.clone())), 191 | }; 192 | actions = actions.push(delete_button); 193 | } 194 | } 195 | 196 | Row::new() 197 | .align_items(Align::Center) 198 | .push(panel) 199 | .push(Space::with_width(Length::Units(10))) 200 | .push(actions) 201 | } 202 | 203 | pub fn get_id(&self) -> &String { 204 | self.course.get_id() 205 | } 206 | 207 | pub fn set_thumbnail(&mut self, thumbnail: Vec) { 208 | self.thumbnail = Some(thumbnail); 209 | } 210 | } 211 | 212 | struct SmmdbCoursePanelButtonStyle(AppState); 213 | 214 | impl button::StyleSheet for SmmdbCoursePanelButtonStyle { 215 | fn active(&self) -> button::Style { 216 | button::Style { 217 | text_color: Color::BLACK, 218 | background: match self.0 { 219 | AppState::DownloadSelect(_) => Some(PANEL_SELECT_ACTIVE), 220 | _ => Some(PANEL_ACTIVE), 221 | }, 222 | border_radius: 8., 223 | ..button::Style::default() 224 | } 225 | } 226 | 227 | fn hovered(&self) -> button::Style { 228 | button::Style { 229 | text_color: Color::BLACK, 230 | background: match self.0 { 231 | AppState::DownloadSelect(_) => Some(PANEL_SELECT_HOVER), 232 | _ => Some(PANEL_ACTIVE), 233 | }, 234 | border_radius: 8., 235 | ..button::Style::default() 236 | } 237 | } 238 | } 239 | 240 | struct SmmdbCoursePanelStyle; 241 | 242 | impl container::StyleSheet for SmmdbCoursePanelStyle { 243 | fn style(&self) -> container::Style { 244 | container::Style { 245 | background: Some(PANEL_ACTIVE), 246 | border_radius: 8., 247 | ..container::Style::default() 248 | } 249 | } 250 | } 251 | struct ThumbnailStyle; 252 | 253 | impl container::StyleSheet for ThumbnailStyle { 254 | fn style(&self) -> container::Style { 255 | container::Style { 256 | background: Some(Background::Color(Color::from_rgba(0., 0., 0., 0.5))), 257 | ..container::Style::default() 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/components/voting_panel.rs: -------------------------------------------------------------------------------- 1 | use crate::{icon, smmdb::SmmdbUser, styles::*, AppState, Message}; 2 | 3 | use iced::{ 4 | button, container, Align, Background, Button, Color, Column, Element, Length, Space, Text, 5 | }; 6 | 7 | #[derive(Clone, Debug)] 8 | pub struct VotingPanel { 9 | upvote_state: button::State, 10 | downvote_state: button::State, 11 | } 12 | 13 | impl VotingPanel { 14 | pub fn new() -> VotingPanel { 15 | VotingPanel { 16 | upvote_state: button::State::new(), 17 | downvote_state: button::State::new(), 18 | } 19 | } 20 | 21 | pub fn view( 22 | &mut self, 23 | course_id: String, 24 | votes: i32, 25 | own_vote: i32, 26 | smmdb_user: Option<&SmmdbUser>, 27 | ) -> impl Into> { 28 | let is_logged_in = smmdb_user.is_some(); 29 | let mut upvote = Button::new( 30 | &mut self.upvote_state, 31 | if own_vote > 0 { 32 | icon::UP_ARROW_GREEN.clone() 33 | } else { 34 | icon::UP_ARROW.clone() 35 | } 36 | .width(Length::Units(24)) 37 | .height(Length::Units(24)), 38 | ) 39 | .style(DefaultButtonStyle); 40 | if is_logged_in { 41 | upvote = match own_vote { 42 | n if n > 0 => upvote.on_press(Message::ResetCourseVote(course_id.clone())), 43 | _ => upvote.on_press(Message::UpvoteCourse(course_id.clone())), 44 | }; 45 | } 46 | 47 | let mut votes = Text::new(format!("{}", votes)); 48 | match own_vote { 49 | n if n > 0 => { 50 | votes = votes.color(TEXT_HIGHLIGHT_COLOR); 51 | } 52 | n if n < 0 => { 53 | votes = votes.color(TEXT_DANGER_COLOR); 54 | } 55 | _ => {} 56 | }; 57 | 58 | let mut downvote = Button::new( 59 | &mut self.downvote_state, 60 | if own_vote < 0 { 61 | icon::DOWN_ARROW_RED.clone() 62 | } else { 63 | icon::DOWN_ARROW.clone() 64 | } 65 | .width(Length::Units(24)) 66 | .height(Length::Units(24)), 67 | ) 68 | .style(DefaultButtonStyle); 69 | if is_logged_in { 70 | downvote = match own_vote { 71 | n if n < 0 => downvote.on_press(Message::ResetCourseVote(course_id)), 72 | _ => downvote.on_press(Message::DownvoteCourse(course_id)), 73 | }; 74 | } 75 | 76 | Column::new() 77 | .width(Length::Units(20)) 78 | .align_items(Align::Center) 79 | .push(upvote) 80 | .push(Space::with_height(Length::Units(16))) 81 | .push(votes) 82 | .push(Space::with_height(Length::Units(16))) 83 | .push(downvote) 84 | } 85 | } 86 | 87 | struct SmmdbCoursePanelButtonStyle(AppState); 88 | 89 | impl button::StyleSheet for SmmdbCoursePanelButtonStyle { 90 | fn active(&self) -> button::Style { 91 | button::Style { 92 | text_color: Color::BLACK, 93 | background: match self.0 { 94 | AppState::DownloadSelect(_) => Some(PANEL_SELECT_ACTIVE), 95 | _ => Some(PANEL_ACTIVE), 96 | }, 97 | border_radius: 8., 98 | ..button::Style::default() 99 | } 100 | } 101 | 102 | fn hovered(&self) -> button::Style { 103 | button::Style { 104 | text_color: Color::BLACK, 105 | background: match self.0 { 106 | AppState::DownloadSelect(_) => Some(PANEL_SELECT_HOVER), 107 | _ => Some(PANEL_ACTIVE), 108 | }, 109 | border_radius: 8., 110 | ..button::Style::default() 111 | } 112 | } 113 | } 114 | 115 | struct SmmdbCoursePanelStyle; 116 | 117 | impl container::StyleSheet for SmmdbCoursePanelStyle { 118 | fn style(&self) -> container::Style { 119 | container::Style { 120 | background: Some(PANEL_ACTIVE), 121 | border_radius: 8., 122 | ..container::Style::default() 123 | } 124 | } 125 | } 126 | struct ThumbnailStyle; 127 | 128 | impl container::StyleSheet for ThumbnailStyle { 129 | fn style(&self) -> container::Style { 130 | container::Style { 131 | background: Some(Background::Color(Color::from_rgba(0., 0., 0., 0.5))), 132 | ..container::Style::default() 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/download.rs: -------------------------------------------------------------------------------- 1 | pub struct Download { 2 | pub url: String, 3 | } 4 | 5 | impl iced_native::subscription::Recipe for Download 6 | where 7 | H: std::hash::Hasher, 8 | { 9 | type Output = Progress; 10 | 11 | fn hash(&self, state: &mut H) { 12 | use std::hash::Hash; 13 | 14 | std::any::TypeId::of::().hash(state); 15 | self.url.hash(state); 16 | } 17 | 18 | fn stream( 19 | self: Box, 20 | _input: futures::stream::BoxStream<'static, I>, 21 | ) -> futures::stream::BoxStream<'static, Self::Output> { 22 | Box::pin(futures::stream::unfold( 23 | State::Ready(self.url), 24 | |state| async move { 25 | match state { 26 | State::Ready(url) => { 27 | let response = reqwest::get(&url).await; 28 | 29 | match response { 30 | Ok(response) => { 31 | if let Some(total) = response.content_length() { 32 | Some(( 33 | Progress::Started, 34 | State::Downloading { 35 | response, 36 | total, 37 | downloaded: 0, 38 | data: vec![], 39 | }, 40 | )) 41 | } else { 42 | Some((Progress::Errored, State::Finished)) 43 | } 44 | } 45 | Err(_) => Some((Progress::Errored, State::Finished)), 46 | } 47 | } 48 | State::Downloading { 49 | mut response, 50 | total, 51 | downloaded, 52 | mut data, 53 | } => match response.chunk().await { 54 | Ok(Some(chunk)) => { 55 | let downloaded = downloaded + chunk.len() as u64; 56 | data.extend(chunk.iter().cloned()); 57 | 58 | let percentage = (downloaded as f32 / total as f32) * 100.0; 59 | 60 | Some(( 61 | Progress::Advanced(percentage), 62 | State::Downloading { 63 | response, 64 | total, 65 | downloaded, 66 | data, 67 | }, 68 | )) 69 | } 70 | Ok(None) => Some((Progress::Finished(data), State::Finished)), 71 | Err(_) => Some((Progress::Errored, State::Finished)), 72 | }, 73 | State::Finished => { 74 | // We do not let the stream die, as it would start a 75 | // new download repeatedly if the user is not careful 76 | // in case of errors. 77 | let _: () = iced::futures::future::pending().await; 78 | 79 | None 80 | } 81 | } 82 | }, 83 | )) 84 | } 85 | } 86 | 87 | #[derive(Debug, Clone)] 88 | pub enum Progress { 89 | Started, 90 | Advanced(f32), 91 | Finished(Vec), 92 | Errored, 93 | } 94 | 95 | pub enum State { 96 | Ready(String), 97 | Downloading { 98 | response: reqwest::Response, 99 | total: u64, 100 | downloaded: u64, 101 | data: Vec, 102 | }, 103 | Finished, 104 | } 105 | -------------------------------------------------------------------------------- /src/emu/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::components::SaveButton; 2 | 3 | use anyhow::Result; 4 | use std::{ 5 | collections::HashSet, 6 | fmt::Write, 7 | fs::{read_dir, File}, 8 | io::Read, 9 | num::ParseIntError, 10 | path::PathBuf, 11 | }; 12 | 13 | mod save; 14 | 15 | pub use save::*; 16 | 17 | pub fn guess_emu_dir() -> Result> { 18 | let mut dirs = vec![]; 19 | let mut found_paths: HashSet = HashSet::new(); 20 | let yuzu_guesses = ["yuzu", "yuzu-emu"]; 21 | let ryujinx_guesses = ["Ryujinx"]; 22 | if let Some(data_dir) = dirs::data_dir() { 23 | guess_dir( 24 | &mut dirs, 25 | &mut found_paths, 26 | data_dir.clone(), 27 | &yuzu_guesses, 28 | EmuType::Yuzu, 29 | is_yuzu_dir, 30 | )?; 31 | guess_dir( 32 | &mut dirs, 33 | &mut found_paths, 34 | data_dir, 35 | &ryujinx_guesses, 36 | EmuType::Ryujinx, 37 | is_ryujinx_dir, 38 | )?; 39 | } 40 | if let Some(config_dir) = dirs::config_dir() { 41 | guess_dir( 42 | &mut dirs, 43 | &mut found_paths, 44 | config_dir.clone(), 45 | &yuzu_guesses, 46 | EmuType::Yuzu, 47 | is_yuzu_dir, 48 | )?; 49 | guess_dir( 50 | &mut dirs, 51 | &mut found_paths, 52 | config_dir, 53 | &ryujinx_guesses, 54 | EmuType::Ryujinx, 55 | is_ryujinx_dir, 56 | )?; 57 | } 58 | if let Some(data_local_dir) = dirs::data_local_dir() { 59 | guess_dir( 60 | &mut dirs, 61 | &mut found_paths, 62 | data_local_dir.clone(), 63 | &yuzu_guesses, 64 | EmuType::Yuzu, 65 | is_yuzu_dir, 66 | )?; 67 | guess_dir( 68 | &mut dirs, 69 | &mut found_paths, 70 | data_local_dir, 71 | &ryujinx_guesses, 72 | EmuType::Ryujinx, 73 | is_ryujinx_dir, 74 | )?; 75 | } 76 | Ok(dirs) 77 | } 78 | 79 | fn guess_dir( 80 | dirs: &mut Vec, 81 | found_paths: &mut HashSet, 82 | dir: PathBuf, 83 | guesses: &[&str], 84 | emu_type: EmuType, 85 | is_emu_type: fn(PathBuf) -> bool, 86 | ) -> Result<()> { 87 | for guess in guesses.iter() { 88 | let mut current_dir = dir.clone(); 89 | current_dir.push(guess); 90 | if current_dir.as_path().exists() && is_emu_type(current_dir.clone()) { 91 | match emu_type { 92 | EmuType::Yuzu => { 93 | current_dir.push("nand/user/save/0000000000000000"); 94 | if !current_dir.as_path().exists() { 95 | continue; 96 | } 97 | for entry in read_dir(current_dir.clone())? { 98 | let entry = entry?; 99 | let mut path = entry.path(); 100 | if path.is_dir() { 101 | path.push("01009B90006DC000"); 102 | if path.exists() && found_paths.get(&path).is_none() { 103 | found_paths.insert(path.clone()); 104 | let display_name = 105 | format!("[{:?}] {}", &emu_type, dir.to_string_lossy()); 106 | dirs.push(SaveButton::new(display_name, path, emu_type.clone())); 107 | } 108 | } 109 | } 110 | } 111 | EmuType::Ryujinx => { 112 | let display_name = 113 | format!("[{:?}] {}", &emu_type, current_dir.to_string_lossy()); 114 | let mut imkvdb_path = current_dir.clone(); 115 | imkvdb_path.push("bis/system/save/8000000000000000/0/imkvdb.arc"); 116 | current_dir.push("bis/user/save"); 117 | 118 | if imkvdb_path.exists() { 119 | let mut imkvdb = File::open(imkvdb_path)?; 120 | let mut buffer = vec![]; 121 | imkvdb.read_to_end(&mut buffer)?; 122 | let mut game_id = decode_hex("01009B90006DC000")?; 123 | game_id.reverse(); 124 | 125 | let imen_header_size = 0xC; 126 | for chunk in buffer[0xC..].chunks(0x80 + imen_header_size) { 127 | if game_id != chunk[imen_header_size..imen_header_size + 8].to_vec() { 128 | continue; 129 | } 130 | let mut save_data_chunks = 131 | chunk[imen_header_size + 0x40..imen_header_size + 0x48].to_vec(); 132 | save_data_chunks.reverse(); 133 | let save_data_id = encode_hex(&save_data_chunks)?; 134 | 135 | let mut path = current_dir.clone(); 136 | path.push(save_data_id); 137 | path.push("0"); 138 | 139 | if path.exists() && found_paths.get(&path).is_none() { 140 | found_paths.insert(path.clone()); 141 | dirs.push(SaveButton::new( 142 | display_name.clone(), 143 | path, 144 | emu_type.clone(), 145 | )); 146 | } 147 | } 148 | } 149 | } 150 | } 151 | } 152 | } 153 | Ok(()) 154 | } 155 | 156 | pub fn is_yuzu_dir(path: PathBuf) -> bool { 157 | let mut system_path = path.clone(); 158 | system_path.push("nand"); 159 | system_path.push("system"); 160 | let mut key_path = path; 161 | key_path.push("keys"); 162 | system_path.as_path().exists() && key_path.as_path().exists() 163 | } 164 | 165 | pub fn is_ryujinx_dir(path: PathBuf) -> bool { 166 | let mut system_path = path.clone(); 167 | system_path.push("system"); 168 | let mut config_path = path; 169 | config_path.push("Config.json"); 170 | system_path.as_path().exists() && config_path.as_path().exists() 171 | } 172 | 173 | fn decode_hex(s: &str) -> Result, ParseIntError> { 174 | (0..s.len()) 175 | .step_by(2) 176 | .map(|i| u8::from_str_radix(&s[i..i + 2], 16)) 177 | .collect() 178 | } 179 | 180 | fn encode_hex(bytes: &[u8]) -> Result { 181 | let mut s = String::with_capacity(bytes.len() * 2); 182 | for &b in bytes { 183 | write!(&mut s, "{:02x}", b)?; 184 | } 185 | Ok(s) 186 | } 187 | -------------------------------------------------------------------------------- /src/emu/save.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::{self, Display}, 3 | path::PathBuf, 4 | }; 5 | 6 | #[derive(Clone, Debug)] 7 | pub struct EmuSave { 8 | display_name: String, 9 | location: PathBuf, 10 | emu_type: EmuType, 11 | } 12 | 13 | #[derive(Clone, Debug)] 14 | pub enum EmuType { 15 | Yuzu, 16 | Ryujinx, 17 | } 18 | 19 | impl EmuSave { 20 | pub fn new(display_name: String, location: PathBuf, emu_type: EmuType) -> EmuSave { 21 | EmuSave { 22 | display_name, 23 | location, 24 | emu_type, 25 | } 26 | } 27 | 28 | pub fn get_display_name(&self) -> &String { 29 | &self.display_name 30 | } 31 | 32 | pub fn get_location(&self) -> &PathBuf { 33 | &self.location 34 | } 35 | } 36 | 37 | impl Display for EmuSave { 38 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 39 | write!(f, "[{:?}] {:?}", self.emu_type, self.location) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/font.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use iced::Font; 4 | 5 | pub const DEFAULT_FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/helvetica.ttf"); 6 | 7 | pub const HELVETICA: Font = Font::External { 8 | name: "Helvetica Bold", 9 | bytes: DEFAULT_FONT_BYTES, 10 | }; 11 | 12 | pub const HELVETICA_BOLD: Font = Font::External { 13 | name: "Helvetica Bold", 14 | bytes: include_bytes!("../assets/fonts/helvetica-bold.ttf"), 15 | }; 16 | 17 | pub const SMME: Font = Font::External { 18 | name: "Super Mario Maker Extended", 19 | bytes: include_bytes!("../assets/fonts/smme.ttf"), 20 | }; 21 | -------------------------------------------------------------------------------- /src/icon.rs: -------------------------------------------------------------------------------- 1 | use iced::{image, svg, Svg}; 2 | 3 | lazy_static! { 4 | pub static ref ADD: Svg = Svg::new(svg::Handle::from_memory( 5 | include_bytes!("../assets/icons/add.svg").to_vec(), 6 | )); 7 | pub static ref UPLOAD: Svg = Svg::new(svg::Handle::from_memory( 8 | include_bytes!("../assets/icons/upload.svg").to_vec(), 9 | )); 10 | pub static ref SORT: Svg = Svg::new(svg::Handle::from_memory( 11 | include_bytes!("../assets/icons/sort.svg").to_vec(), 12 | )); 13 | pub static ref DELETE: Svg = Svg::new(svg::Handle::from_memory( 14 | include_bytes!("../assets/icons/delete.svg").to_vec(), 15 | )); 16 | pub static ref SETTINGS: Svg = Svg::new(svg::Handle::from_memory( 17 | include_bytes!("../assets/icons/settings.svg").to_vec(), 18 | )); 19 | pub static ref DOWN_ARROW: Svg = Svg::new(svg::Handle::from_memory( 20 | include_bytes!("../assets/icons/down_arrow.svg").to_vec(), 21 | )); 22 | pub static ref DOWN_ARROW_RED: Svg = Svg::new(svg::Handle::from_memory( 23 | include_bytes!("../assets/icons/down_arrow_red.svg").to_vec(), 24 | )); 25 | pub static ref UP_ARROW: Svg = Svg::new(svg::Handle::from_memory( 26 | include_bytes!("../assets/icons/up_arrow.svg").to_vec(), 27 | )); 28 | pub static ref UP_ARROW_GREEN: Svg = Svg::new(svg::Handle::from_memory( 29 | include_bytes!("../assets/icons/up_arrow_green.svg").to_vec(), 30 | )); 31 | pub static ref EASY: image::Handle = 32 | image::Handle::from_memory(include_bytes!("../assets/icons/easy.png").to_vec(),); 33 | pub static ref NORMAL: image::Handle = 34 | image::Handle::from_memory(include_bytes!("../assets/icons/normal.png").to_vec(),); 35 | pub static ref EXPERT: image::Handle = 36 | image::Handle::from_memory(include_bytes!("../assets/icons/expert.png").to_vec(),); 37 | pub static ref SUPER_EXPERT: image::Handle = 38 | image::Handle::from_memory(include_bytes!("../assets/icons/superexpert.png").to_vec(),); 39 | } 40 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate lazy_static; 3 | 4 | mod app; 5 | mod components; 6 | mod download; 7 | mod emu; 8 | mod font; 9 | mod icon; 10 | mod pages; 11 | mod settings; 12 | mod smmdb; 13 | mod styles; 14 | mod widgets; 15 | 16 | pub use app::{AppErrorState, AppState, Message}; 17 | pub use download::{Download, Progress}; 18 | pub use emu::{EmuSave, EmuType}; 19 | pub use pages::Page; 20 | pub use settings::Settings; 21 | pub use smmdb::Smmdb; 22 | 23 | use anyhow::Result; 24 | 25 | fn main() -> Result<()> { 26 | use app::*; 27 | use iced::{window, Application}; 28 | 29 | std::env::set_var("RUST_BACKTRACE", "1"); 30 | 31 | human_panic::setup_panic!(); 32 | 33 | let icon = match image::load_from_memory(include_bytes!("../assets/icons/icon.png")) { 34 | Ok(buffer) => { 35 | let buffer = buffer.to_rgba8(); 36 | let width = buffer.width(); 37 | let height = buffer.height(); 38 | let dynamic_image = image::DynamicImage::ImageRgba8(buffer); 39 | match iced::window::icon::Icon::from_rgba(dynamic_image.to_bytes(), width, height) { 40 | Ok(icon) => Some(icon), 41 | Err(_) => None, 42 | } 43 | } 44 | Err(_) => None, 45 | }; 46 | let window = window::Settings { 47 | min_size: Some((960, 500)), 48 | icon, 49 | ..window::Settings::default() 50 | }; 51 | let settings = iced::Settings { 52 | antialiasing: true, 53 | window, 54 | default_font: Some(font::DEFAULT_FONT_BYTES), 55 | ..iced::Settings::default() 56 | }; 57 | 58 | match App::run(settings) { 59 | Err(iced::Error::GraphicsAdapterNotFound) => { 60 | panic!("Your GPU does not seem to support the Vulkan graphics API"); 61 | } 62 | Ok(_) => Ok(()), 63 | Err(err) => Err(err), 64 | }?; 65 | Ok(()) 66 | } 67 | -------------------------------------------------------------------------------- /src/pages/init.rs: -------------------------------------------------------------------------------- 1 | use crate::{components::SaveButton, font::*, styles::*, AppErrorState, AppState, Message}; 2 | 3 | use iced::{button, Button, Column, Element, Length, Space, Text}; 4 | 5 | #[derive(Clone, Debug)] 6 | pub struct InitPage { 7 | open_custom_save: button::State, 8 | save_buttons: Vec, 9 | } 10 | 11 | impl InitPage { 12 | pub fn new(save_buttons: Vec) -> InitPage { 13 | InitPage { 14 | open_custom_save: button::State::new(), 15 | save_buttons, 16 | } 17 | } 18 | } 19 | 20 | impl InitPage { 21 | pub fn view(&mut self, state: &AppState, error_state: &AppErrorState) -> Element { 22 | let mut content = self.save_buttons.iter_mut().fold( 23 | Column::new() 24 | .padding(CONTAINER_PADDING) 25 | .spacing(LIST_SPACING), 26 | |acc, save_button| acc.push(save_button.view(state)), 27 | ); 28 | 29 | let mut custom_save_button = Button::new( 30 | &mut self.open_custom_save, 31 | Text::new("Select another save folder"), 32 | ) 33 | .padding(BUTTON_PADDING) 34 | .style(DefaultButtonStyle); 35 | custom_save_button = match state { 36 | AppState::Loading => custom_save_button, 37 | _ => custom_save_button.on_press(Message::OpenCustomSave), 38 | }; 39 | content = content.push(custom_save_button); 40 | 41 | content = if let AppErrorState::Some(err) = error_state { 42 | content.push(Space::with_height(Length::Units(16))).push( 43 | Text::new(err) 44 | .font(HELVETICA_BOLD) 45 | .size(22) 46 | .color(COLOR_DARK_RED), 47 | ) 48 | } else { 49 | content 50 | }; 51 | 52 | Column::new() 53 | .push(Text::new("Please select your save folder").size(36)) 54 | .push(content) 55 | .padding(CONTAINER_PADDING) 56 | .spacing(PAGE_SPACING) 57 | .into() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/pages/mod.rs: -------------------------------------------------------------------------------- 1 | mod init; 2 | mod save; 3 | mod settings; 4 | 5 | pub use init::InitPage; 6 | pub use save::SavePage; 7 | pub use settings::SettingsPage; 8 | 9 | #[derive(Clone, Debug)] 10 | pub enum Page { 11 | Init(InitPage), 12 | Save(Box), 13 | Settings(SettingsPage), 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/save.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | smmdb::Course2Response, 3 | widgets::{SaveWidget, SmmdbTab, SmmdbWidget}, 4 | AppState, Message, Smmdb, 5 | }; 6 | 7 | use anyhow::Result; 8 | use iced::{Element, Row}; 9 | use std::collections::HashMap; 10 | 11 | #[derive(Clone, Debug)] 12 | pub struct SavePage { 13 | save: smmdb_lib::Save, 14 | display_name: String, 15 | save_widget: SaveWidget, 16 | smmdb_widget: SmmdbWidget, 17 | } 18 | 19 | impl SavePage { 20 | pub fn new( 21 | save: smmdb_lib::Save, 22 | display_name: String, 23 | course_responses: &HashMap, 24 | ) -> SavePage { 25 | SavePage { 26 | save_widget: SaveWidget::new(&save, course_responses), 27 | save, 28 | display_name, 29 | smmdb_widget: SmmdbWidget::new(), 30 | } 31 | } 32 | 33 | pub fn set_course_response(&mut self, courses: &HashMap) { 34 | self.save_widget.set_course_response(courses); 35 | self.generate_course_panels(courses); 36 | } 37 | 38 | pub fn set_smmdb_tab(&mut self, tab: SmmdbTab) { 39 | self.smmdb_widget.set_smmdb_tab(tab); 40 | } 41 | 42 | pub fn view<'a>(&'a mut self, state: &AppState, smmdb: &'a mut Smmdb) -> Element { 43 | Row::new() 44 | .push( 45 | self.save_widget 46 | .view(state, &self.display_name, smmdb.get_user()), 47 | ) 48 | .push(self.smmdb_widget.view(state, smmdb)) 49 | .into() 50 | } 51 | 52 | pub async fn swap_courses( 53 | &mut self, 54 | first: u8, 55 | second: u8, 56 | course_responses: &HashMap, 57 | ) -> Result<()> { 58 | self.save.swap_course(first, second)?; 59 | self.save 60 | .save() 61 | .await 62 | .map_err(|err| -> anyhow::Error { err.into() })?; 63 | self.generate_course_panels(course_responses); 64 | Ok(()) 65 | } 66 | 67 | pub async fn add_course( 68 | &mut self, 69 | index: u8, 70 | course: smmdb_lib::Course2, 71 | course_responses: &HashMap, 72 | ) -> Result<()> { 73 | self.save.add_course(index, course)?; 74 | self.save 75 | .save() 76 | .await 77 | .map_err(|err| -> anyhow::Error { err.into() })?; 78 | self.generate_course_panels(course_responses); 79 | Ok(()) 80 | } 81 | 82 | pub async fn delete_course( 83 | &mut self, 84 | index: u8, 85 | course_responses: &HashMap, 86 | ) -> Result<()> { 87 | self.save.remove_course(index)?; 88 | self.save 89 | .save() 90 | .await 91 | .map_err(|err| -> anyhow::Error { err.into() })?; 92 | self.generate_course_panels(course_responses); 93 | Ok(()) 94 | } 95 | 96 | fn generate_course_panels(&mut self, course_responses: &HashMap) { 97 | self.save_widget 98 | .regenerate_course_panels(&self.save, course_responses); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/pages/settings.rs: -------------------------------------------------------------------------------- 1 | use crate::{font::*, styles::*, AppErrorState, Message, Page, Settings}; 2 | 3 | use iced::{ 4 | button, text_input, Button, Column, Element, Length, Row, Rule, Space, Text, TextInput, 5 | }; 6 | 7 | #[derive(Clone, Debug)] 8 | pub struct SettingsPage { 9 | settings: Settings, 10 | has_apikey: bool, 11 | has_changed: bool, 12 | prev_page: Box, 13 | apikey: text_input::State, 14 | unset_apikey: button::State, 15 | save: button::State, 16 | close: button::State, 17 | } 18 | 19 | impl SettingsPage { 20 | pub fn new(mut settings: Settings, prev_page: Page) -> SettingsPage { 21 | let has_apikey = settings.apikey.is_some(); 22 | settings.apikey = None; 23 | SettingsPage { 24 | settings, 25 | has_apikey, 26 | has_changed: false, 27 | prev_page: Box::new(prev_page), 28 | apikey: text_input::State::new(), 29 | unset_apikey: button::State::new(), 30 | save: button::State::new(), 31 | close: button::State::new(), 32 | } 33 | } 34 | 35 | pub fn set_apikey(&mut self, apikey: String) { 36 | self.settings.apikey = Some(apikey); 37 | self.has_changed = true; 38 | } 39 | 40 | pub fn unset_apikey(&mut self) { 41 | self.has_changed = self.has_apikey; 42 | self.settings.apikey = None; 43 | self.has_apikey = false; 44 | } 45 | 46 | pub fn get_prev_page(&self) -> Page { 47 | *self.prev_page.clone() 48 | } 49 | 50 | pub fn view(&mut self, error_state: &AppErrorState) -> Element { 51 | let empty = "".to_string(); 52 | let mut content = Column::new() 53 | .padding(CONTAINER_PADDING) 54 | .spacing(LIST_SPACING) 55 | .push(Text::new("API key:").font(HELVETICA_BOLD)) 56 | .push( 57 | TextInput::new( 58 | &mut self.apikey, 59 | if self.has_apikey { 60 | "You are already logged in. If you want to log in with a different account, please insert your API key." 61 | } else { 62 | "API key" 63 | }, 64 | &self.settings.apikey.as_ref().unwrap_or(&empty), 65 | Message::ChangeApiKey, 66 | ) 67 | .padding(4), 68 | ) 69 | .push( 70 | Text::new( 71 | "\ 72 | Open https://smmdb.net/profile in your browser and sign in with Google. \ 73 | Paste the API key given there in this text field.", 74 | ) 75 | .size(14) 76 | .color(TEXT_HELP_COLOR), 77 | ); 78 | 79 | if self.has_apikey { 80 | content = content.push( 81 | Button::new(&mut self.unset_apikey, Text::new("Logout")) 82 | .style(DefaultButtonDangerStyle) 83 | .on_press(Message::ResetApiKey), 84 | ) 85 | } 86 | 87 | content = content.push(Space::with_height(Length::Units(24))); 88 | 89 | content = if let AppErrorState::Some(err) = error_state { 90 | content.push(Space::with_height(Length::Units(16))).push( 91 | Text::new(err) 92 | .font(HELVETICA_BOLD) 93 | .size(22) 94 | .color(COLOR_DARK_RED), 95 | ) 96 | } else { 97 | content 98 | }; 99 | 100 | let mut buttons = Row::new(); 101 | if self.has_changed { 102 | buttons = buttons 103 | .push( 104 | Button::new(&mut self.save, Text::new("Save and close")) 105 | .style(DefaultButtonStyle) 106 | .on_press(Message::TrySaveSettings(self.settings.clone())), 107 | ) 108 | .push(Space::with_width(Length::Units(12))); 109 | } 110 | buttons = buttons.push( 111 | Button::new(&mut self.close, Text::new("Close")) 112 | .style(DefaultButtonDangerStyle) 113 | .on_press(Message::CloseSettings), 114 | ); 115 | 116 | content = content.push(Rule::horizontal(4)).push(buttons); 117 | 118 | Column::new() 119 | .push(Text::new("Settings").size(36)) 120 | .push(content) 121 | .padding(CONTAINER_PADDING) 122 | .spacing(PAGE_SPACING) 123 | .into() 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{ 4 | fs::{create_dir, read, write, File}, 5 | io::Write, 6 | path::PathBuf, 7 | }; 8 | 9 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 10 | pub struct Settings { 11 | pub apikey: Option, 12 | } 13 | 14 | impl Settings { 15 | pub fn load() -> Result { 16 | let settings_path = Settings::get_path()?; 17 | let settings = if let Ok(settings) = read(settings_path.clone()) { 18 | serde_json::from_slice(&settings)? 19 | } else { 20 | let settings = Settings::default(); 21 | let mut file = File::create(settings_path)?; 22 | file.write_all(serde_json::to_string(&settings)?.as_bytes())?; 23 | settings 24 | }; 25 | Ok(settings) 26 | } 27 | 28 | fn get_path() -> Result { 29 | let mut config_dir = if let Some(config_dir) = dirs::config_dir() { 30 | config_dir 31 | } else { 32 | dirs::data_dir().expect("Could not initialize app directory") 33 | }; 34 | config_dir.push("smmdb-client"); 35 | if !config_dir.exists() { 36 | create_dir(config_dir.clone())?; 37 | } 38 | let mut settings_path = config_dir; 39 | settings_path.push("settings.json"); 40 | Ok(settings_path) 41 | } 42 | 43 | pub fn save(&self) -> Result<()> { 44 | let settings_path = Settings::get_path()?; 45 | let settings = serde_json::to_string(&self)?; 46 | write(settings_path, settings)?; 47 | Ok(()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/smmdb.rs: -------------------------------------------------------------------------------- 1 | use crate::{components::SmmdbCoursePanel, Download, Progress}; 2 | 3 | use anyhow::Result; 4 | use iced::Subscription; 5 | use indexmap::IndexMap; 6 | use reqwest::{header, Client}; 7 | use serde::{Deserialize, Serialize}; 8 | use smmdb_lib::{proto::SMM2Course::SMM2Course, SavedCourse}; 9 | use std::{ 10 | collections::HashMap, 11 | fmt, 12 | io::{self, ErrorKind}, 13 | }; 14 | 15 | #[derive(Clone, Debug, Deserialize)] 16 | pub struct SmmdbUser { 17 | pub id: String, 18 | pub username: String, 19 | } 20 | 21 | #[derive(Clone, Debug, Deserialize)] 22 | pub struct UploadResponse { 23 | pub succeeded: Vec, 24 | pub failed: Vec, 25 | } 26 | 27 | #[derive(Clone, Debug, Deserialize)] 28 | pub struct UploadSucceededResponse { 29 | pub id: String, 30 | } 31 | 32 | #[derive(Clone, Debug, Deserialize)] 33 | #[serde(rename_all = "camelCase")] 34 | pub struct UploadFailedResponse { 35 | similar_course_id: String, 36 | title: String, 37 | jaccard: f32, 38 | } 39 | 40 | #[derive(Debug)] 41 | pub struct Smmdb { 42 | client: Client, 43 | apikey: Option, 44 | user: Option, 45 | query_params: QueryParams, 46 | course_responses: HashMap, 47 | course_panels: IndexMap, 48 | own_query_params: QueryParams, 49 | own_course_responses: HashMap, 50 | own_course_panels: IndexMap, 51 | } 52 | 53 | impl Smmdb { 54 | pub fn new(apikey: Option) -> Smmdb { 55 | Smmdb { 56 | client: Client::new(), 57 | apikey, 58 | user: None, 59 | query_params: serde_json::from_str::("{}").unwrap(), 60 | course_responses: HashMap::new(), 61 | course_panels: IndexMap::new(), 62 | own_query_params: serde_json::from_str::("{}").unwrap(), 63 | own_course_responses: HashMap::new(), 64 | own_course_panels: IndexMap::new(), 65 | } 66 | } 67 | 68 | pub fn get_user(&self) -> Option<&SmmdbUser> { 69 | self.user.as_ref() 70 | } 71 | 72 | pub fn set_user(&mut self, user: Option) { 73 | self.user = user; 74 | if let Some(user) = &self.user { 75 | self.own_query_params.owner = Some(user.id.clone()); 76 | } else { 77 | self.own_query_params = serde_json::from_str::("{}").unwrap(); 78 | self.own_course_responses.clear(); 79 | self.own_course_panels.clear(); 80 | } 81 | } 82 | 83 | pub fn set_courses(&mut self, courses: Vec, update_panels: bool) { 84 | courses 85 | .iter() 86 | .cloned() 87 | .map(|course| (course.get_id().clone(), course)) 88 | .for_each(|(smmdb_id, course_response)| { 89 | self.course_responses.insert(smmdb_id, course_response); 90 | }); 91 | if update_panels { 92 | self.course_panels.clear(); 93 | courses 94 | .into_iter() 95 | .map(SmmdbCoursePanel::new) 96 | .for_each(|course| { 97 | self.course_panels.insert(course.get_id().clone(), course); 98 | }); 99 | } 100 | } 101 | 102 | pub fn delete_course_response(&mut self, id: String) { 103 | self.course_responses.remove(&id); 104 | self.own_course_responses.remove(&id); 105 | } 106 | 107 | pub fn set_own_courses(&mut self, courses: Vec, update_panels: bool) { 108 | courses 109 | .iter() 110 | .cloned() 111 | .map(|course| (course.get_id().clone(), course)) 112 | .for_each(|(smmdb_id, course_response)| { 113 | self.own_course_responses.insert(smmdb_id, course_response); 114 | }); 115 | if update_panels { 116 | self.own_course_panels.clear(); 117 | courses 118 | .into_iter() 119 | .map(SmmdbCoursePanel::new) 120 | .for_each(|course| { 121 | self.own_course_panels 122 | .insert(course.get_id().clone(), course); 123 | }); 124 | } 125 | } 126 | 127 | pub fn get_course_responses(&self) -> &HashMap { 128 | &self.course_responses 129 | } 130 | 131 | pub fn set_course_panel_thumbnail(&mut self, id: &str, thumbnail: Vec) { 132 | if let Some(course_panel) = self.course_panels.get_mut(id) { 133 | course_panel.set_thumbnail(thumbnail.clone()); 134 | } 135 | if let Some(course_panel) = self.own_course_panels.get_mut(id) { 136 | course_panel.set_thumbnail(thumbnail); 137 | } 138 | } 139 | 140 | pub fn get_course_panels(&mut self) -> &mut IndexMap { 141 | &mut self.course_panels 142 | } 143 | 144 | pub fn get_own_course_panels(&mut self) -> &mut IndexMap { 145 | &mut self.own_course_panels 146 | } 147 | 148 | pub fn get_query_params(&self) -> &QueryParams { 149 | &self.query_params 150 | } 151 | 152 | pub fn get_own_query_params(&self) -> &QueryParams { 153 | &self.own_query_params 154 | } 155 | 156 | pub fn can_paginate_forward(&self) -> bool { 157 | self.course_panels.len() as u32 == self.query_params.limit 158 | } 159 | 160 | pub fn can_self_paginate_forward(&self) -> bool { 161 | self.own_course_panels.len() as u32 == self.own_query_params.limit 162 | } 163 | 164 | pub fn can_paginate_backward(&self) -> bool { 165 | self.query_params.skip > 0 166 | } 167 | 168 | pub fn can_self_paginate_backward(&self) -> bool { 169 | self.own_query_params.skip > 0 170 | } 171 | 172 | pub fn paginate_forward(&mut self) { 173 | self.query_params.skip += self.query_params.limit; 174 | } 175 | 176 | pub fn self_paginate_forward(&mut self) { 177 | self.own_query_params.skip += self.own_query_params.limit; 178 | } 179 | 180 | pub fn paginate_backward(&mut self) { 181 | self.query_params.skip -= self.query_params.limit; 182 | } 183 | 184 | pub fn self_paginate_backward(&mut self) { 185 | self.own_query_params.skip -= self.own_query_params.limit; 186 | } 187 | 188 | pub fn reset_pagination(&mut self) { 189 | self.query_params.skip = 0; 190 | } 191 | 192 | pub fn reset_self_pagination(&mut self) { 193 | self.own_query_params.skip = 0; 194 | } 195 | 196 | pub fn set_title(&mut self, title: String) { 197 | if let "" = title.as_ref() { 198 | self.query_params.title = None; 199 | } else { 200 | self.query_params.title = Some(title); 201 | } 202 | } 203 | 204 | pub fn set_uploader(&mut self, uploader: String) { 205 | if let "" = uploader.as_ref() { 206 | self.query_params.uploader = None; 207 | } else { 208 | self.query_params.uploader = Some(uploader); 209 | } 210 | } 211 | 212 | pub fn set_difficulty(&mut self, difficulty: Difficulty) { 213 | if let Difficulty::Unset = difficulty { 214 | self.query_params.difficulty = None; 215 | } else { 216 | self.query_params.difficulty = Some(difficulty); 217 | } 218 | } 219 | 220 | pub fn set_sort(&mut self, sort: SortOptions) { 221 | self.query_params.sort = Some(sort); 222 | } 223 | 224 | pub fn set_apikey(&mut self, apikey: String) { 225 | self.apikey = Some(apikey); 226 | } 227 | 228 | pub fn set_own_vote(&mut self, course_id: String, value: i32) { 229 | if let Some(course) = self.course_panels.get_mut(&course_id) { 230 | course.set_own_vote(value); 231 | } 232 | if let Some(course) = self.own_course_panels.get_mut(&course_id) { 233 | course.set_own_vote(value); 234 | } 235 | if let Some(course) = self.course_responses.get_mut(&course_id) { 236 | course.set_own_vote(value) 237 | } 238 | } 239 | 240 | pub async fn update( 241 | query_params: QueryParams, 242 | apikey: Option, 243 | ) -> Result> { 244 | let qs = serde_qs::to_string(&query_params) 245 | .map_err(|err| io::Error::new(ErrorKind::Other, err.to_string()))?; 246 | let mut client = Client::new().get(&format!("https://api.smmdb.net/courses2?{}", qs)); 247 | if let Some(apikey) = apikey { 248 | client = client.header(header::AUTHORIZATION, &format!("APIKEY {}", apikey)); 249 | } 250 | 251 | let body = client.send().await?.text().await?; 252 | let response: Vec = serde_json::from_str(&body)?; 253 | Ok(response) 254 | } 255 | 256 | pub async fn update_self( 257 | query_params: QueryParams, 258 | apikey: Option, 259 | ) -> Result> { 260 | let qs = serde_qs::to_string(&query_params) 261 | .map_err(|err| io::Error::new(ErrorKind::Other, err.to_string()))?; 262 | let mut client = Client::new().get(&format!("https://api.smmdb.net/courses2?{}", qs)); 263 | if let Some(apikey) = apikey { 264 | client = client.header(header::AUTHORIZATION, &format!("APIKEY {}", apikey)); 265 | } 266 | 267 | let body = client.send().await?.text().await?; 268 | let response: Vec = serde_json::from_str(&body)?; 269 | Ok(response) 270 | } 271 | 272 | pub async fn fetch_thumbnail(id: String) -> Result> { 273 | let bytes = Client::new() 274 | .get(&format!( 275 | "https://api.smmdb.net/courses2/thumbnail/{}?size=m", 276 | id 277 | )) 278 | .send() 279 | .await? 280 | .bytes() 281 | .await?; 282 | Ok(bytes.into_iter().collect()) 283 | } 284 | 285 | pub fn download_course(id: String) -> Subscription { 286 | Subscription::from_recipe(Download { 287 | url: format!("https://api.smmdb.net/courses2/download/{}", id), 288 | }) 289 | } 290 | 291 | pub async fn upload_course(course: SavedCourse, apikey: String) -> Result { 292 | let file = course.get_course().as_zip()?; 293 | let client = Client::new() 294 | .put("https://api.smmdb.net/courses2") 295 | .header(header::AUTHORIZATION, &format!("APIKEY {}", apikey)) 296 | .header(header::CONTENT_TYPE, "application/zip") 297 | .header(header::CONTENT_LENGTH, file.len()) 298 | .body(file); 299 | 300 | let body = client.send().await?.text().await?; 301 | let response: UploadResponse = serde_json::from_str(&body)?; 302 | Ok(response) 303 | } 304 | 305 | pub async fn delete_course(id: String, apikey: String) -> Result<()> { 306 | Client::new() 307 | .delete(format!("https://api.smmdb.net/courses2/{}", id)) 308 | .header(header::AUTHORIZATION, &format!("APIKEY {}", apikey)) 309 | .send() 310 | .await?; 311 | 312 | Ok(()) 313 | } 314 | 315 | pub async fn try_sign_in(apikey: String) -> std::result::Result { 316 | match Client::new() 317 | .post("https://api.smmdb.net/login") 318 | .header(header::AUTHORIZATION, &format!("APIKEY {}", apikey)) 319 | .send() 320 | .await 321 | { 322 | Ok(response) => { 323 | if response.status().is_success() { 324 | let body = response.text().await.unwrap(); 325 | Ok(serde_json::from_str(&body).unwrap()) 326 | } else { 327 | Err("Could not sign in! Your API key seems to be wrong.".to_string()) 328 | } 329 | } 330 | Err(err) => Err(err.to_string()), 331 | } 332 | } 333 | 334 | pub async fn vote( 335 | course_id: String, 336 | value: i32, 337 | apikey: String, 338 | ) -> std::result::Result<(), String> { 339 | let body = serde_json::to_string(&VoteBody { value }).map_err(|err| err.to_string())?; 340 | match Client::new() 341 | .post(&format!( 342 | "https://api.smmdb.net/courses2/vote/{}", 343 | course_id 344 | )) 345 | .header(header::AUTHORIZATION, &format!("APIKEY {}", apikey)) 346 | .header(header::CONTENT_TYPE, "application/json") 347 | .body(body) 348 | .send() 349 | .await 350 | { 351 | Ok(response) => { 352 | if response.status().is_success() { 353 | Ok(()) 354 | } else { 355 | Err("Could not sign in! Your API key seems to be wrong.".to_string()) 356 | } 357 | } 358 | Err(err) => Err(err.to_string()), 359 | } 360 | } 361 | } 362 | 363 | #[derive(Clone, Debug, Deserialize, Serialize)] 364 | #[serde(rename_all = "camelCase")] 365 | pub struct Course2Response { 366 | id: String, 367 | owner: String, 368 | uploader: String, 369 | #[serde(skip_serializing_if = "Option::is_none")] 370 | difficulty: Option, 371 | last_modified: i64, 372 | uploaded: i64, 373 | votes: i32, 374 | #[serde(default)] 375 | own_vote: i32, 376 | course: SMM2Course, 377 | } 378 | 379 | impl Course2Response { 380 | pub fn get_id(&self) -> &String { 381 | &self.id 382 | } 383 | 384 | pub fn get_owner(&self) -> &String { 385 | &self.owner 386 | } 387 | 388 | pub fn get_votes(&self) -> i32 { 389 | self.votes 390 | } 391 | 392 | pub fn get_own_vote(&self) -> i32 { 393 | self.own_vote 394 | } 395 | 396 | pub fn set_own_vote(&mut self, value: i32) { 397 | let diff = value - self.own_vote; 398 | self.votes += diff; 399 | self.own_vote = value; 400 | } 401 | 402 | pub fn get_course(&self) -> &SMM2Course { 403 | &self.course 404 | } 405 | 406 | pub fn get_difficulty(&self) -> Option<&Difficulty> { 407 | self.difficulty.as_ref() 408 | } 409 | } 410 | 411 | #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] 412 | #[serde(rename_all = "lowercase")] 413 | pub enum Difficulty { 414 | Unset, 415 | Easy, 416 | Normal, 417 | Expert, 418 | SuperExpert, 419 | } 420 | 421 | impl Difficulty { 422 | pub const ALL: [Difficulty; 5] = [ 423 | Difficulty::Unset, 424 | Difficulty::Easy, 425 | Difficulty::Normal, 426 | Difficulty::Expert, 427 | Difficulty::SuperExpert, 428 | ]; 429 | } 430 | 431 | impl fmt::Display for Difficulty { 432 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 433 | match self { 434 | Difficulty::Unset => write!(f, ""), 435 | Difficulty::Easy => write!(f, "Easy"), 436 | Difficulty::Normal => write!(f, "Normal"), 437 | Difficulty::Expert => write!(f, "Expert"), 438 | Difficulty::SuperExpert => write!(f, "SuperExpert"), 439 | } 440 | } 441 | } 442 | 443 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 444 | pub struct QueryParams { 445 | #[serde(default = "limit_default")] 446 | pub limit: u32, 447 | #[serde(default)] 448 | pub skip: u32, 449 | #[serde(default)] 450 | pub id: Option, 451 | #[serde(default)] 452 | pub ids: Option>, 453 | #[serde(default)] 454 | pub title: Option, 455 | #[serde(default)] 456 | pub title_exact: bool, 457 | #[serde(default)] 458 | pub title_case_sensitive: bool, 459 | #[serde(default = "is_true")] 460 | pub title_trimmed: bool, 461 | #[serde(default)] 462 | pub owner: Option, 463 | #[serde(default)] 464 | pub uploader: Option, 465 | #[serde(default)] 466 | pub sort: Option, 467 | #[serde(default)] 468 | pub difficulty: Option, 469 | } 470 | 471 | #[derive(Clone, Debug, Deserialize, Serialize)] 472 | pub struct VoteBody { 473 | #[serde(default)] 474 | pub value: i32, 475 | } 476 | 477 | impl QueryParams { 478 | pub fn get_title(&self) -> &str { 479 | if let Some(title) = self.title.as_ref() { 480 | title 481 | } else { 482 | "" 483 | } 484 | } 485 | 486 | pub fn get_uploader(&self) -> &str { 487 | if let Some(uploader) = self.uploader.as_ref() { 488 | uploader 489 | } else { 490 | "" 491 | } 492 | } 493 | 494 | pub fn get_sort(&self) -> Option { 495 | self.sort.clone() 496 | } 497 | 498 | pub fn get_difficulty(&self) -> Option { 499 | self.difficulty 500 | } 501 | } 502 | 503 | fn limit_default() -> u32 { 504 | 25 505 | } 506 | 507 | fn is_true() -> bool { 508 | true 509 | } 510 | 511 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 512 | pub struct Sort { 513 | pub val: SortValue, 514 | dir: i32, 515 | } 516 | 517 | impl Default for Sort { 518 | fn default() -> Self { 519 | Sort { 520 | val: SortValue::LastModified, 521 | dir: -1, 522 | } 523 | } 524 | } 525 | 526 | #[derive(Clone, Debug, Deserialize, Eq, Serialize)] 527 | pub struct SortOptions(Vec); 528 | 529 | lazy_static! { 530 | pub static ref SORT_OPTIONS: [SortOptions; 2] = [ 531 | SortOptions(vec![Sort { 532 | val: SortValue::LastModified, 533 | dir: -1, 534 | }]), 535 | SortOptions(vec![ 536 | Sort { 537 | val: SortValue::Votes, 538 | dir: -1, 539 | }, 540 | Sort { 541 | val: SortValue::LastModified, 542 | dir: -1, 543 | }, 544 | ]), 545 | ]; 546 | } 547 | 548 | impl fmt::Display for SortOptions { 549 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 550 | match self.0.get(0).unwrap().val { 551 | SortValue::LastModified => write!(f, "Last Modified"), 552 | SortValue::Uploaded => write!(f, "Uploaded"), 553 | SortValue::CourseHeaderTitle => write!(f, "Title"), 554 | SortValue::Votes => write!(f, "Votes"), 555 | } 556 | } 557 | } 558 | 559 | impl PartialEq for SortOptions { 560 | fn eq(&self, other: &Self) -> bool { 561 | self.0.eq(&other.0) 562 | } 563 | } 564 | 565 | #[derive(Clone, Deserialize, Debug, Eq, PartialEq, Serialize)] 566 | pub enum SortValue { 567 | #[serde(rename = "last_modified")] 568 | LastModified, 569 | #[serde(rename = "uploaded")] 570 | Uploaded, 571 | #[serde(rename = "course.header.title")] 572 | CourseHeaderTitle, 573 | #[serde(rename = "votes")] 574 | Votes, 575 | } 576 | -------------------------------------------------------------------------------- /src/styles.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use iced::{button, container, pick_list, text_input, Background, Color}; 4 | 5 | // Spacings 6 | pub const CONTAINER_PADDING: u16 = 20; 7 | pub const TAB_PADDING: u16 = 12; 8 | pub const BUTTON_PADDING: u16 = 8; 9 | pub const TAB_BUTTON_PADDING: u16 = 6; 10 | pub const PAGE_SPACING: u16 = 20; 11 | pub const LIST_SPACING: u16 = 12; 12 | pub const TAB_SPACING: u16 = 2; 13 | 14 | // Colors 15 | pub const COLOR_YELLOW: Color = Color::from_rgb(1., 0.812, 0.); 16 | pub const COLOR_YELLOW2: Color = Color::from_rgb(1., 0.86, 0.2); 17 | pub const COLOR_LIGHT_YELLOW: Color = Color::from_rgb(0.996, 0.952, 0.827); 18 | pub const COLOR_LIGHTER_YELLOW: Color = Color::from_rgb(1., 0.992, 0.933); 19 | pub const COLOR_GREEN: Color = Color::from_rgb(0., 0.592, 0.518); 20 | pub const COLOR_LIGHT_GREEN: Color = Color::from_rgb(0.665, 0.941, 0.598); 21 | pub const COLOR_LIGHTER_GREEN: Color = Color::from_rgb(0.85, 1., 0.85); 22 | pub const COLOR_RED: Color = Color::from_rgb(1., 0., 0.); 23 | pub const COLOR_DARK_RED: Color = Color::from_rgb(0.6, 0., 0.); 24 | pub const COLOR_BROWN: Color = Color::from_rgb(0.38, 0.094, 0.129); 25 | pub const COLOR_GRAY: Color = Color::from_rgb(0.4, 0.4, 0.4); 26 | 27 | // Panels 28 | pub const PANEL_ACTIVE: Background = Background::Color(Color::WHITE); 29 | pub const PANEL_DOWNLOAD: Background = Background::Color(COLOR_LIGHTER_GREEN); 30 | pub const PANEL_SELECT_ACTIVE: Background = Background::Color(COLOR_LIGHTER_GREEN); 31 | pub const PANEL_SELECT_HOVER: Background = Background::Color(COLOR_LIGHT_GREEN); 32 | pub const PANEL_TAB: Background = Background::Color(COLOR_YELLOW2); 33 | 34 | // Buttons 35 | pub const BUTTON_ACTIVE: Background = Background::Color(Color::WHITE); 36 | pub const BUTTON_TAB_ACTIVE: Background = Background::Color(COLOR_LIGHT_YELLOW); 37 | pub const BUTTON_SELECT_ACTIVE: Background = Background::Color(COLOR_LIGHTER_YELLOW); 38 | pub const BUTTON_SELECT_CANCEL: Background = Background::Color(COLOR_RED); 39 | pub const BUTTON_HOVER: Background = Background::Color(COLOR_LIGHT_GREEN); 40 | pub const BUTTON_SELECT_HOVER: Background = Background::Color(COLOR_LIGHT_YELLOW); 41 | pub const BUTTON_CONFIRM: Background = Background::Color(COLOR_GREEN); 42 | pub const BUTTON_DANGER: Background = Background::Color(COLOR_RED); 43 | pub const BUTTON_DISABLED: Background = Background::Color(COLOR_GRAY); 44 | 45 | // TextInput 46 | pub const TEXT_INPUT_ACTIVE: Background = Background::Color(Color::WHITE); 47 | pub const TEXT_INPUT_FOCUS: Background = Background::Color(COLOR_LIGHTER_GREEN); 48 | pub const TEXT_INPUT_PLACEHOLDER_COLOR: Color = COLOR_GRAY; 49 | pub const TEXT_INPUT_VALUE_COLOR: Color = Color::BLACK; 50 | pub const TEXT_INPUT_SELECTION_COLOR: Color = COLOR_LIGHT_GREEN; 51 | 52 | // Text 53 | pub const TEXT_HELP_COLOR: Color = COLOR_GRAY; 54 | pub const TEXT_HIGHLIGHT_COLOR: Color = COLOR_GREEN; 55 | pub const TEXT_DANGER_COLOR: Color = COLOR_RED; 56 | 57 | // PickList 58 | pub const PICK_LIST_MENU: Background = Background::Color(Color::WHITE); 59 | pub const PICK_LIST_ACTIVE: Background = Background::Color(Color::WHITE); 60 | pub const PICK_LIST_HOVER: Background = Background::Color(COLOR_LIGHTER_GREEN); 61 | pub const PICK_LIST_SELECT: Background = Background::Color(COLOR_LIGHT_GREEN); 62 | 63 | pub struct DefaultButtonStyle; 64 | 65 | impl button::StyleSheet for DefaultButtonStyle { 66 | fn active(&self) -> button::Style { 67 | button::Style { 68 | background: Some(BUTTON_ACTIVE), 69 | border_radius: 4., 70 | ..button::Style::default() 71 | } 72 | } 73 | 74 | fn hovered(&self) -> button::Style { 75 | button::Style { 76 | background: Some(BUTTON_HOVER), 77 | border_radius: 4., 78 | ..button::Style::default() 79 | } 80 | } 81 | } 82 | 83 | pub struct DefaultButtonDangerStyle; 84 | 85 | impl button::StyleSheet for DefaultButtonDangerStyle { 86 | fn active(&self) -> button::Style { 87 | button::Style { 88 | background: Some(BUTTON_ACTIVE), 89 | border_radius: 4., 90 | ..button::Style::default() 91 | } 92 | } 93 | 94 | fn hovered(&self) -> button::Style { 95 | button::Style { 96 | background: Some(BUTTON_DANGER), 97 | border_radius: 4., 98 | ..button::Style::default() 99 | } 100 | } 101 | } 102 | 103 | pub struct DeleteButtonStyle; 104 | 105 | impl button::StyleSheet for DeleteButtonStyle { 106 | fn active(&self) -> button::Style { 107 | button::Style { 108 | background: Some(BUTTON_ACTIVE), 109 | border_radius: 4., 110 | border_width: 0., 111 | ..button::Style::default() 112 | } 113 | } 114 | 115 | fn hovered(&self) -> button::Style { 116 | button::Style { 117 | text_color: Color::WHITE, 118 | background: Some(BUTTON_DANGER), 119 | border_radius: 4., 120 | ..button::Style::default() 121 | } 122 | } 123 | 124 | fn disabled(&self) -> button::Style { 125 | button::Style { 126 | background: Some(BUTTON_DISABLED), 127 | border_radius: 4., 128 | ..button::Style::default() 129 | } 130 | } 131 | } 132 | 133 | pub struct TabButtonStyle(pub bool); 134 | 135 | impl button::StyleSheet for TabButtonStyle { 136 | fn active(&self) -> button::Style { 137 | button::Style { 138 | background: Some(if self.0 { 139 | BUTTON_TAB_ACTIVE 140 | } else { 141 | BUTTON_ACTIVE 142 | }), 143 | border_radius: 2., 144 | border_width: 1., 145 | border_color: if self.0 { 146 | Color::BLACK 147 | } else { 148 | Color::TRANSPARENT 149 | }, 150 | ..button::Style::default() 151 | } 152 | } 153 | 154 | fn hovered(&self) -> button::Style { 155 | button::Style { 156 | background: Some(if self.0 { 157 | BUTTON_TAB_ACTIVE 158 | } else { 159 | BUTTON_HOVER 160 | }), 161 | border_radius: 2., 162 | border_width: 1., 163 | border_color: if self.0 { 164 | Color::BLACK 165 | } else { 166 | Color::TRANSPARENT 167 | }, 168 | ..button::Style::default() 169 | } 170 | } 171 | } 172 | 173 | pub struct TabContainerStyle; 174 | 175 | impl container::StyleSheet for TabContainerStyle { 176 | fn style(&self) -> container::Style { 177 | container::Style { 178 | background: Some(PANEL_TAB), 179 | border_radius: 8., 180 | ..container::Style::default() 181 | } 182 | } 183 | } 184 | 185 | pub struct DefaultTextInputStyle; 186 | 187 | impl text_input::StyleSheet for DefaultTextInputStyle { 188 | fn active(&self) -> text_input::Style { 189 | text_input::Style { 190 | background: TEXT_INPUT_ACTIVE, 191 | border_radius: 4., 192 | ..text_input::Style::default() 193 | } 194 | } 195 | 196 | fn focused(&self) -> text_input::Style { 197 | text_input::Style { 198 | background: TEXT_INPUT_FOCUS, 199 | border_radius: 4., 200 | ..text_input::Style::default() 201 | } 202 | } 203 | 204 | fn placeholder_color(&self) -> Color { 205 | TEXT_INPUT_PLACEHOLDER_COLOR 206 | } 207 | 208 | fn value_color(&self) -> Color { 209 | TEXT_INPUT_VALUE_COLOR 210 | } 211 | 212 | fn selection_color(&self) -> Color { 213 | TEXT_INPUT_SELECTION_COLOR 214 | } 215 | } 216 | 217 | pub struct DefaultPickListStyle; 218 | 219 | impl pick_list::StyleSheet for DefaultPickListStyle { 220 | fn menu(&self) -> pick_list::Menu { 221 | pick_list::Menu { 222 | background: PICK_LIST_MENU, 223 | selected_background: PICK_LIST_SELECT, 224 | ..pick_list::Menu::default() 225 | } 226 | } 227 | 228 | fn active(&self) -> pick_list::Style { 229 | pick_list::Style { 230 | background: PICK_LIST_ACTIVE, 231 | border_radius: 4., 232 | ..pick_list::Style::default() 233 | } 234 | } 235 | 236 | fn hovered(&self) -> pick_list::Style { 237 | pick_list::Style { 238 | background: PICK_LIST_HOVER, 239 | border_radius: 4., 240 | ..pick_list::Style::default() 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/widgets/courses_widget.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | font, 3 | smmdb::{Difficulty, SortOptions, SORT_OPTIONS}, 4 | styles::*, 5 | AppState, Message, Smmdb, 6 | }; 7 | 8 | use iced::{ 9 | button, pick_list, text_input, Align, Button, Column, Element, Length, PickList, Row, Space, 10 | Text, TextInput, 11 | }; 12 | 13 | #[derive(Clone, Debug)] 14 | pub struct CoursesWidget { 15 | title_state: text_input::State, 16 | uploader_state: text_input::State, 17 | difficulty_state: pick_list::State, 18 | sort_state: pick_list::State, 19 | search_state: button::State, 20 | backward_state: button::State, 21 | forward_state: button::State, 22 | } 23 | 24 | impl CoursesWidget { 25 | pub fn new() -> CoursesWidget { 26 | CoursesWidget { 27 | title_state: text_input::State::new(), 28 | uploader_state: text_input::State::new(), 29 | difficulty_state: pick_list::State::default(), 30 | sort_state: pick_list::State::default(), 31 | search_state: button::State::new(), 32 | backward_state: button::State::new(), 33 | forward_state: button::State::new(), 34 | } 35 | } 36 | 37 | pub fn view<'a>( 38 | &'a mut self, 39 | state: &AppState, 40 | smmdb: &'a mut Smmdb, 41 | ) -> impl Into> { 42 | let query_params = smmdb.get_query_params(); 43 | 44 | let title_text_input = TextInput::new( 45 | &mut self.title_state, 46 | "Title", 47 | query_params.get_title(), 48 | Message::TitleChanged, 49 | ) 50 | .style(DefaultTextInputStyle) 51 | .padding(4); 52 | let uploader_text_input = TextInput::new( 53 | &mut self.uploader_state, 54 | "Uploader", 55 | query_params.get_uploader(), 56 | Message::UploaderChanged, 57 | ) 58 | .style(DefaultTextInputStyle) 59 | .padding(4); 60 | let difficulty_pick_list = PickList::new( 61 | &mut self.difficulty_state, 62 | &Difficulty::ALL[..], 63 | query_params.get_difficulty(), 64 | Message::DifficultyChanged, 65 | ) 66 | .style(DefaultPickListStyle) 67 | .padding(4); 68 | let sort_pick_list = PickList::new( 69 | &mut self.sort_state, 70 | &SORT_OPTIONS[..], 71 | query_params.get_sort(), 72 | Message::SortChanged, 73 | ) 74 | .style(DefaultPickListStyle) 75 | .padding(4); 76 | let search_button = Button::new(&mut self.search_state, Text::new("Search")) 77 | .style(DefaultButtonStyle) 78 | .on_press(Message::ApplyFilters); 79 | 80 | let filter = Column::new() 81 | .push(Text::new("Filters:").font(font::HELVETICA_BOLD).size(16)) 82 | .push(title_text_input) 83 | .push(Space::with_height(Length::Units(4))) 84 | .push(uploader_text_input) 85 | .push(Space::with_height(Length::Units(4))) 86 | .push(difficulty_pick_list) 87 | .push(Space::with_height(Length::Units(4))) 88 | .push(Space::with_height(Length::Units(8))) 89 | .push(Text::new("Sort by:").font(font::HELVETICA_BOLD).size(16)) 90 | .push(sort_pick_list) 91 | .push(Space::with_height(Length::Units(4))) 92 | .push(search_button); 93 | 94 | let paginate_text = Text::new(&format!( 95 | "{} – {}", 96 | query_params.skip + 1, 97 | query_params.skip + smmdb.get_course_panels().len() as u32 98 | )); 99 | 100 | let mut backward_button = Button::new(&mut self.backward_state, Text::new("<").size(24)) 101 | .style(DefaultButtonStyle); 102 | backward_button = match state { 103 | AppState::Loading | AppState::Downloading { .. } => backward_button, 104 | _ => { 105 | if smmdb.can_paginate_backward() { 106 | backward_button.on_press(Message::PaginateBackward) 107 | } else { 108 | backward_button 109 | } 110 | } 111 | }; 112 | let mut forward_button = 113 | Button::new(&mut self.forward_state, Text::new(">").size(24)).style(DefaultButtonStyle); 114 | forward_button = match state { 115 | AppState::Loading | AppState::Downloading { .. } => forward_button, 116 | _ => { 117 | if smmdb.can_paginate_forward() { 118 | forward_button.on_press(Message::PaginateForward) 119 | } else { 120 | forward_button 121 | } 122 | } 123 | }; 124 | 125 | let paginator = Row::new() 126 | .align_items(Align::Center) 127 | .push(Space::with_width(Length::Fill)) 128 | .push(paginate_text) 129 | .push(Space::with_width(Length::Units(16))) 130 | .push(backward_button) 131 | .push(Space::with_width(Length::Units(16))) 132 | .push(forward_button); 133 | 134 | let mut content = Column::new() 135 | .padding(TAB_PADDING) 136 | .spacing(LIST_SPACING) 137 | .push(filter) 138 | .push(Space::with_height(Length::Units(8))) 139 | .push(paginator); 140 | 141 | let smmdb_user = smmdb.get_user().cloned(); 142 | for panel in smmdb.get_course_panels().values_mut() { 143 | content = content.push(panel.view(state, smmdb_user.as_ref())); 144 | } 145 | 146 | content.width(Length::FillPortion(1)) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | mod courses_widget; 2 | mod save_widget; 3 | mod smmdb_widget; 4 | mod uploads_widget; 5 | 6 | pub use courses_widget::*; 7 | pub use save_widget::*; 8 | pub use smmdb_widget::*; 9 | pub use uploads_widget::*; 10 | -------------------------------------------------------------------------------- /src/widgets/save_widget.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | components::CoursePanel, 3 | font, 4 | smmdb::{Course2Response, SmmdbUser}, 5 | styles::*, 6 | AppState, 7 | }; 8 | 9 | use iced::{scrollable, Element, Length, Scrollable, Text}; 10 | use smmdb_lib::CourseEntry; 11 | use std::collections::HashMap; 12 | 13 | #[derive(Clone, Debug)] 14 | pub struct SaveWidget { 15 | state: scrollable::State, 16 | course_panels: Vec, 17 | } 18 | 19 | impl SaveWidget { 20 | pub fn new( 21 | save: &smmdb_lib::Save, 22 | course_responses: &HashMap, 23 | ) -> SaveWidget { 24 | let course_panels = Self::generate_course_panels(save, course_responses); 25 | SaveWidget { 26 | state: scrollable::State::new(), 27 | course_panels, 28 | } 29 | } 30 | 31 | pub fn set_course_response(&mut self, courses: &HashMap) { 32 | self.course_panels 33 | .iter_mut() 34 | .filter_map(|course_panel| { 35 | if let Some(smmdb_id) = course_panel.get_smmdb_id() { 36 | courses 37 | .get(&smmdb_id) 38 | .map(|course_response| (course_response, course_panel)) 39 | } else { 40 | course_panel.delete_response(); 41 | None 42 | } 43 | }) 44 | .for_each(|(course_response, course_panel)| { 45 | course_panel.set_response(course_response.clone()); 46 | }) 47 | } 48 | 49 | pub fn view<'a>( 50 | &'a mut self, 51 | state: &AppState, 52 | display_name: &str, 53 | smmdb_user: Option<&SmmdbUser>, 54 | ) -> Element { 55 | let mut content = Scrollable::new(&mut self.state) 56 | .padding(CONTAINER_PADDING) 57 | .spacing(LIST_SPACING) 58 | .push(Text::new(display_name).font(font::SMME)); 59 | for (index, panel) in self.course_panels.iter_mut().enumerate() { 60 | content = content.push(panel.view(state, index, smmdb_user)); 61 | } 62 | 63 | content.width(Length::FillPortion(1)).into() 64 | } 65 | 66 | pub fn regenerate_course_panels( 67 | &mut self, 68 | save: &smmdb_lib::Save, 69 | course_responses: &HashMap, 70 | ) { 71 | self.course_panels = Self::generate_course_panels(save, course_responses); 72 | } 73 | 74 | fn generate_course_panels( 75 | save: &smmdb_lib::Save, 76 | course_responses: &HashMap, 77 | ) -> Vec { 78 | save.get_own_courses() 79 | .iter() 80 | .map(|course| { 81 | let course_response = if let Some(course) = course { 82 | if let CourseEntry::SavedCourse(course) = &**course { 83 | if let Some(smmdb_id) = course.get_course().get_smmdb_id() { 84 | course_responses.get(&smmdb_id).cloned() 85 | } else { 86 | None 87 | } 88 | } else { 89 | None 90 | } 91 | } else { 92 | None 93 | }; 94 | CoursePanel::new(course.clone(), course_response) 95 | }) 96 | .collect() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/widgets/smmdb_widget.rs: -------------------------------------------------------------------------------- 1 | use super::{CoursesWidget, UploadsWidget}; 2 | use crate::{font, styles::*, AppState, Message, Smmdb}; 3 | 4 | use iced::{ 5 | button, scrollable, Align, Button, Container, Element, Length, Row, Scrollable, Space, Text, 6 | }; 7 | 8 | #[derive(Clone, Debug, PartialEq)] 9 | pub enum SmmdbTab { 10 | Courses, 11 | Uploads, 12 | } 13 | 14 | #[derive(Clone, Debug)] 15 | pub struct SmmdbWidget { 16 | tab: SmmdbTab, 17 | courses_widget: CoursesWidget, 18 | uploads_widget: UploadsWidget, 19 | state: scrollable::State, 20 | courses_state: button::State, 21 | uploads_state: button::State, 22 | } 23 | 24 | impl SmmdbWidget { 25 | pub fn new() -> SmmdbWidget { 26 | SmmdbWidget { 27 | tab: SmmdbTab::Courses, 28 | courses_widget: CoursesWidget::new(), 29 | uploads_widget: UploadsWidget::new(), 30 | state: scrollable::State::new(), 31 | courses_state: button::State::new(), 32 | uploads_state: button::State::new(), 33 | } 34 | } 35 | 36 | pub fn set_smmdb_tab(&mut self, tab: SmmdbTab) { 37 | self.tab = tab; 38 | } 39 | 40 | pub fn view<'a>( 41 | &'a mut self, 42 | state: &AppState, 43 | smmdb: &'a mut Smmdb, 44 | ) -> impl Into> { 45 | let courses_button = Button::new(&mut self.courses_state, Text::new("Courses".to_string())) 46 | .style(TabButtonStyle(self.tab == SmmdbTab::Courses)) 47 | .padding(TAB_BUTTON_PADDING) 48 | .on_press(Message::SetSmmdbTab(SmmdbTab::Courses)); 49 | let uploads_button = Button::new(&mut self.uploads_state, Text::new("Uploads".to_string())) 50 | .style(TabButtonStyle(self.tab == SmmdbTab::Uploads)) 51 | .padding(TAB_BUTTON_PADDING) 52 | .on_press(Message::SetSmmdbTab(SmmdbTab::Uploads)); 53 | 54 | let tab_buttons = Row::new() 55 | .padding(CONTAINER_PADDING) 56 | .spacing(LIST_SPACING) 57 | .align_items(Align::Center) 58 | .push(courses_button) 59 | .push(uploads_button); 60 | 61 | let tab_content = Container::new(match self.tab { 62 | SmmdbTab::Courses => self.courses_widget.view(state, smmdb).into(), 63 | SmmdbTab::Uploads => self.uploads_widget.view(state, smmdb).into(), 64 | }) 65 | .width(Length::Fill) 66 | .style(TabContainerStyle); 67 | 68 | Scrollable::new(&mut self.state) 69 | .padding(CONTAINER_PADDING) 70 | .spacing(TAB_SPACING) 71 | .width(Length::FillPortion(1)) 72 | .push(Text::new("SMMDB").font(font::SMME)) 73 | .push(Space::with_height(Length::Units(8))) 74 | .push(tab_buttons) 75 | .push(tab_content) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/widgets/uploads_widget.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | font::*, 3 | smmdb::{Difficulty, SortOptions}, 4 | styles::*, 5 | AppState, Message, Smmdb, 6 | }; 7 | 8 | use iced::{ 9 | button, pick_list, text_input, Align, Button, Column, Element, Length, Row, Space, Text, 10 | }; 11 | 12 | #[derive(Clone, Debug)] 13 | pub struct UploadsWidget { 14 | title_state: text_input::State, 15 | uploader_state: text_input::State, 16 | difficulty_state: pick_list::State, 17 | sort_state: pick_list::State, 18 | search_state: button::State, 19 | backward_state: button::State, 20 | forward_state: button::State, 21 | } 22 | 23 | impl UploadsWidget { 24 | pub fn new() -> UploadsWidget { 25 | UploadsWidget { 26 | title_state: text_input::State::new(), 27 | uploader_state: text_input::State::new(), 28 | difficulty_state: pick_list::State::default(), 29 | sort_state: pick_list::State::default(), 30 | search_state: button::State::new(), 31 | backward_state: button::State::new(), 32 | forward_state: button::State::new(), 33 | } 34 | } 35 | 36 | pub fn view<'a>( 37 | &'a mut self, 38 | state: &AppState, 39 | smmdb: &'a mut Smmdb, 40 | ) -> impl Into> { 41 | let mut content = Column::new() 42 | .padding(TAB_PADDING) 43 | .spacing(LIST_SPACING) 44 | .width(Length::FillPortion(1)); 45 | 46 | content = if smmdb.get_user().is_none() { 47 | content 48 | .align_items(Align::Center) 49 | .push(Space::with_height(Length::Units(36))) 50 | .push( 51 | Text::new( 52 | "You are not yet logged in! Please open settings and paste your API key.", 53 | ) 54 | .font(HELVETICA_BOLD) 55 | .size(20), 56 | ) 57 | .push(Space::with_height(Length::Units(36))) 58 | } else if smmdb.get_own_course_panels().is_empty() { 59 | content 60 | .align_items(Align::Center) 61 | .push(Space::with_height(Length::Units(36))) 62 | .push( 63 | Text::new("You don't yet have any uploaded courses!") 64 | .font(HELVETICA_BOLD) 65 | .size(20), 66 | ) 67 | .push(Space::with_height(Length::Units(36))) 68 | } else { 69 | let query_params = smmdb.get_own_query_params(); 70 | 71 | let paginate_text = Text::new(&format!( 72 | "{} – {}", 73 | query_params.skip + 1, 74 | query_params.skip + smmdb.get_own_course_panels().len() as u32 75 | )); 76 | 77 | let mut backward_button = 78 | Button::new(&mut self.backward_state, Text::new("<").size(24)) 79 | .style(DefaultButtonStyle); 80 | backward_button = match state { 81 | AppState::Loading | AppState::Downloading { .. } => backward_button, 82 | _ => { 83 | if smmdb.can_self_paginate_backward() { 84 | backward_button.on_press(Message::PaginateSelfBackward) 85 | } else { 86 | backward_button 87 | } 88 | } 89 | }; 90 | let mut forward_button = Button::new(&mut self.forward_state, Text::new(">").size(24)) 91 | .style(DefaultButtonStyle); 92 | forward_button = match state { 93 | AppState::Loading | AppState::Downloading { .. } => forward_button, 94 | _ => { 95 | if smmdb.can_self_paginate_forward() { 96 | forward_button.on_press(Message::PaginateSelfForward) 97 | } else { 98 | forward_button 99 | } 100 | } 101 | }; 102 | 103 | let paginator = Row::new() 104 | .align_items(Align::Center) 105 | .push(Space::with_width(Length::Fill)) 106 | .push(paginate_text) 107 | .push(Space::with_width(Length::Units(16))) 108 | .push(backward_button) 109 | .push(Space::with_width(Length::Units(16))) 110 | .push(forward_button); 111 | 112 | let smmdb_user = smmdb.get_user().cloned(); 113 | for panel in smmdb.get_own_course_panels().values_mut() { 114 | content = content.push(panel.view(state, smmdb_user.as_ref())); 115 | } 116 | 117 | content.push(paginator) 118 | }; 119 | 120 | content 121 | } 122 | } 123 | --------------------------------------------------------------------------------