diff --git a/.gitignore b/.gitignore index a05b0ad2..026332e7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ ssl/*.pem *!fastlane/Fastfile *!fastlane/Pluginfile *.xcbkptlist -*.xcuserstate \ No newline at end of file +*.xcuserstate +cache/* \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index c3e1872f..794d241a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ build = "build.rs" [dependencies] actix = "0.13.1" +actix-files = "0.6.5" actix-governor = "0.5.0" actix-http = "3.4" actix-identity = "0.4.0" diff --git a/README.md b/README.md index 202b94d1..fe98c058 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,32 @@ SEASONS + **EVENTS** is a comma separated list of all events the app should use
+ **SEASONS** is a comma separated list of all seasons this app has been used ### ssl -an ssl certificate is *required*, and must be placed in the ssl directory, with filenames `key.pem` and `cert.pem`. for local testing, one can be self-signed using the following command (run from the bearTracks directory created by setup.sh) +A ssl certificate is *required*, and must be placed in the ssl directory, with filenames `key.pem` and `cert.pem`. For local testing, one can be self-signed using the following command (run from the bearTracks directory created by setup.sh) ```sh openssl req -x509 -newkey rsa:4096 -nodes -keyout ./ssl/key.pem -out ./ssl/cert.pem -days 365 -subj '/CN=localhost' ``` +For use on production, replace `` with your domain, and run this with port 80 free. +```sh +# new certificate. run commands from ~/bearTracks +certbot certonly --standalone --keep-until-expiring --agree-tos -d "" +cp /etc/letsencrypt/live/.io/cert.pem ssl/cert.pem +cp /etc/letsencrypt/live/.io/privkey.pem ssl/key.pem +# renew certificate. run from ~/bearTracks +certbot renew +cp /etc/letsencrypt/live/.io/cert.pem ssl/cert.pem +cp /etc/letsencrypt/live/.io/privkey.pem ssl/key.pem +``` +### running server +To start the server from a ssh session, run the following command from the ~/bearTracks directory. +```sh +nohup ./bear_tracks & +``` +you may now exit the ssh session. +To stop bearTracks, run +```sh +./service.sh stop +``` + ## iOS & macOS apps @@ -38,13 +60,14 @@ The clients are broken into 3 apps- Data, Scout, and Manage. Manage is intended | iOS 17 | ✅ | ✅ | ✅ | | iOS 16 | ✅ | ❌ | ❌ | | macOS 14 | ✅ | ✅ | ✅ | -| macOS 11-13 | ✅ | ❌ | ❌ | -| App Store | ⌛ | ✅ | ❌ | +| macOS 13 | ✅ | ❌ | ❌ | +| App Store | ✅ | ✅ | ❌ | | Web | ⌛ | ❌ | ❌ | | Android | ❌ | ❌ | ❌ | -android users may use web (pwa) + +android users may use web [Data iOS](https://apps.apple.com/app/beartracks-data/id6475752596)
[Data macOS](https://apps.apple.com/app/beartracks-data/id6475752596)
-[Scout iOS](https://testflight.apple.com/join/0LzEHahN)
+[Scout iOS](https://apps.apple.com/app/beartracks-scout/id6476092907)
[Manage Xcode](https://github.com/JayAgra/bearTracks/tree/main/ios/beartracks-manage) \ No newline at end of file diff --git a/data_auth.db b/data_auth.db index d4cdffb9..36115981 100644 Binary files a/data_auth.db and b/data_auth.db differ diff --git a/ios/bearTracks.xcodeproj/project.pbxproj b/ios/bearTracks.xcodeproj/project.pbxproj index 0f5c8f01..5aa23a3a 100644 --- a/ios/bearTracks.xcodeproj/project.pbxproj +++ b/ios/bearTracks.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + F5081BA22B741D15001497DB /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5081BA12B741D15001497DB /* AppState.swift */; }; F540A13D2B549D2500611384 /* TeamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F540A13C2B549D2500611384 /* TeamView.swift */; }; F540A13F2B549D2E00611384 /* TeamViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F540A13E2B549D2E00611384 /* TeamViewModel.swift */; }; F54E76C22B527D96003C65A2 /* bearTracksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F54E76C12B527D96003C65A2 /* bearTracksApp.swift */; }; @@ -18,7 +19,6 @@ F5AE2E4C2B527E050033DB0D /* DataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE2E452B527E050033DB0D /* DataView.swift */; }; F5AE2E532B527E170033DB0D /* Teams.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE2E4D2B527E170033DB0D /* Teams.swift */; }; F5AE2E542B527E170033DB0D /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE2E4E2B527E170033DB0D /* SettingsManager.swift */; }; - F5AE2E552B527E170033DB0D /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE2E4F2B527E170033DB0D /* TabBar.swift */; }; F5AE2E582B527E170033DB0D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE2E522B527E170033DB0D /* SettingsView.swift */; }; F5AE2E5A2B5288FB0033DB0D /* URLSessionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE2E592B5288FB0033DB0D /* URLSessionConfiguration.swift */; }; F5AE2E5C2B52FD430033DB0D /* LoginStateValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE2E5B2B52FD430033DB0D /* LoginStateValidator.swift */; }; @@ -49,6 +49,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + F5081BA12B741D15001497DB /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; F540A13C2B549D2500611384 /* TeamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamView.swift; sourceTree = ""; }; F540A13E2B549D2E00611384 /* TeamViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamViewModel.swift; sourceTree = ""; }; F54E76BE2B527D96003C65A2 /* bearTracks.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bearTracks.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -61,7 +62,6 @@ F5AE2E452B527E050033DB0D /* DataView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DataView.swift; path = bearTracks/DataView.swift; sourceTree = SOURCE_ROOT; }; F5AE2E4D2B527E170033DB0D /* Teams.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Teams.swift; sourceTree = ""; }; F5AE2E4E2B527E170033DB0D /* SettingsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; - F5AE2E4F2B527E170033DB0D /* TabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; F5AE2E522B527E170033DB0D /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; F5AE2E592B5288FB0033DB0D /* URLSessionConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionConfiguration.swift; sourceTree = ""; }; F5AE2E5B2B52FD430033DB0D /* LoginStateValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginStateValidator.swift; sourceTree = ""; }; @@ -100,8 +100,9 @@ F54E76C02B527D96003C65A2 /* bearTracks */ = { isa = PBXGroup; children = ( - F5D3D3452B5A17AE00A88A00 /* Info.plist */, F5D3D3402B5A098D00A88A00 /* bearTracks.entitlements */, + F5D3D3452B5A17AE00A88A00 /* Info.plist */, + F5081BA12B741D15001497DB /* AppState.swift */, F54E76C12B527D96003C65A2 /* bearTracksApp.swift */, F5AE2E452B527E050033DB0D /* DataView.swift */, F5AE2E442B527E050033DB0D /* DataViewModel.swift */, @@ -111,7 +112,6 @@ F5AE2E3F2B527E050033DB0D /* MatchList.swift */, F5AE2E4E2B527E170033DB0D /* SettingsManager.swift */, F5AE2E522B527E170033DB0D /* SettingsView.swift */, - F5AE2E4F2B527E170033DB0D /* TabBar.swift */, F5AE2E4D2B527E170033DB0D /* Teams.swift */, F540A13C2B549D2500611384 /* TeamView.swift */, F540A13E2B549D2E00611384 /* TeamViewModel.swift */, @@ -212,8 +212,8 @@ F5AE2E542B527E170033DB0D /* SettingsManager.swift in Sources */, F5AE2E5C2B52FD430033DB0D /* LoginStateValidator.swift in Sources */, F5AE2E4B2B527E050033DB0D /* DataViewModel.swift in Sources */, + F5081BA22B741D15001497DB /* AppState.swift in Sources */, F540A13D2B549D2500611384 /* TeamView.swift in Sources */, - F5AE2E552B527E170033DB0D /* TabBar.swift in Sources */, F5AE2E4C2B527E050033DB0D /* DataView.swift in Sources */, F5AE2E462B527E050033DB0D /* MatchList.swift in Sources */, ); @@ -274,7 +274,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -331,7 +331,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -350,27 +350,32 @@ CODE_SIGN_ENTITLEMENTS = bearTracks/bearTracks.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"bearTracks/Preview Content\""; DEVELOPMENT_TEAM = D6MFYYVHA8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = bearTracks/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bearTracks; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Jayen Agrawal. All rights reserved."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = ""; INFOPLIST_KEY_UIStatusBarStyle = ""; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.1; + MARKETING_VERSION = 5.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.jayagra.beartracks; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,6"; @@ -386,27 +391,32 @@ CODE_SIGN_ENTITLEMENTS = bearTracks/bearTracks.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"bearTracks/Preview Content\""; DEVELOPMENT_TEAM = D6MFYYVHA8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = bearTracks/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bearTracks; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Jayen Agrawal. All rights reserved."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = ""; INFOPLIST_KEY_UIStatusBarStyle = ""; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.1; + MARKETING_VERSION = 5.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.jayagra.beartracks; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,6"; diff --git a/ios/beartracks-scout.xcodeproj/project.pbxproj b/ios/beartracks-scout.xcodeproj/project.pbxproj index d008f718..92f860c5 100644 --- a/ios/beartracks-scout.xcodeproj/project.pbxproj +++ b/ios/beartracks-scout.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - F509F6B52B54D7A8006FD9D5 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F509F6B42B54D7A8006FD9D5 /* TabBar.swift */; }; F509F6B72B54D7EA006FD9D5 /* StartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F509F6B62B54D7EA006FD9D5 /* StartView.swift */; }; F509F6B92B54D802006FD9D5 /* GameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F509F6B82B54D802006FD9D5 /* GameView.swift */; }; F509F6BB2B54D809006FD9D5 /* EndView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F509F6BA2B54D809006FD9D5 /* EndView.swift */; }; @@ -22,11 +21,9 @@ F565840D2B54D52A00F587C0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F565840C2B54D52A00F587C0 /* Assets.xcassets */; }; F56584102B54D52A00F587C0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F565840F2B54D52A00F587C0 /* Preview Assets.xcassets */; }; F5AD47CC2B54FF0500345122 /* ReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AD47CB2B54FF0500345122 /* ReviewView.swift */; }; - F5AD47CE2B55050E00345122 /* SubmitSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AD47CD2B55050E00345122 /* SubmitSheetView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - F509F6B42B54D7A8006FD9D5 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; F509F6B62B54D7EA006FD9D5 /* StartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartView.swift; sourceTree = ""; }; F509F6B82B54D802006FD9D5 /* GameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameView.swift; sourceTree = ""; }; F509F6BA2B54D809006FD9D5 /* EndView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndView.swift; sourceTree = ""; }; @@ -41,8 +38,8 @@ F56584082B54D52900F587C0 /* beartracks_scoutApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = beartracks_scoutApp.swift; sourceTree = ""; }; F565840C2B54D52A00F587C0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; F565840F2B54D52A00F587C0 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + F58565C32B7572B400C1606E /* beartracks-scout.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "beartracks-scout.entitlements"; sourceTree = ""; }; F5AD47CB2B54FF0500345122 /* ReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewView.swift; sourceTree = ""; }; - F5AD47CD2B55050E00345122 /* SubmitSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmitSheetView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -75,6 +72,7 @@ F56584072B54D52900F587C0 /* beartracks-scout */ = { isa = PBXGroup; children = ( + F58565C32B7572B400C1606E /* beartracks-scout.entitlements */, F56584082B54D52900F587C0 /* beartracks_scoutApp.swift */, F509F6BA2B54D809006FD9D5 /* EndView.swift */, F509F6B82B54D802006FD9D5 /* GameView.swift */, @@ -86,8 +84,6 @@ F509F6BE2B54D8F2006FD9D5 /* SettingsManager.swift */, F509F6BC2B54D810006FD9D5 /* SettingsView.swift */, F509F6B62B54D7EA006FD9D5 /* StartView.swift */, - F5AD47CD2B55050E00345122 /* SubmitSheetView.swift */, - F509F6B42B54D7A8006FD9D5 /* TabBar.swift */, F509F6C02B54D9C0006FD9D5 /* URLSessionConfiguration.swift */, F565840C2B54D52A00F587C0 /* Assets.xcassets */, F565840E2B54D52A00F587C0 /* Preview Content */, @@ -177,8 +173,6 @@ F509F6C12B54D9C0006FD9D5 /* URLSessionConfiguration.swift in Sources */, F509F6BB2B54D809006FD9D5 /* EndView.swift in Sources */, F509F6C92B54E554006FD9D5 /* PressModifier.swift in Sources */, - F5AD47CE2B55050E00345122 /* SubmitSheetView.swift in Sources */, - F509F6B52B54D7A8006FD9D5 /* TabBar.swift in Sources */, F5AD47CC2B54FF0500345122 /* ReviewView.swift in Sources */, F509F6BF2B54D8F2006FD9D5 /* SettingsManager.swift in Sources */, F509F6C32B54D9DA006FD9D5 /* LoginStateValidator.swift in Sources */, @@ -317,13 +311,15 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "beartracks-scout/beartracks-scout.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"beartracks-scout/Preview Content\""; DEVELOPMENT_TEAM = D6MFYYVHA8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Scout; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -335,7 +331,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.1; + MARKETING_VERSION = 5.0.3; PRODUCT_BUNDLE_IDENTIFIER = "com.jayagra.beartracks-scout"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -349,13 +345,15 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "beartracks-scout/beartracks-scout.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"beartracks-scout/Preview Content\""; DEVELOPMENT_TEAM = D6MFYYVHA8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Scout; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -367,7 +365,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.1; + MARKETING_VERSION = 5.0.3; PRODUCT_BUNDLE_IDENTIFIER = "com.jayagra.beartracks-scout"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/Contents.json index 8b5650e1..3f620ad8 100644 --- a/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "IMG_0804-2-2-3.jpeg", + "filename" : "l0_icon1.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/IMG_0804-2-2-3.jpeg b/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/IMG_0804-2-2-3.jpeg deleted file mode 100644 index c47e0212..00000000 Binary files a/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/IMG_0804-2-2-3.jpeg and /dev/null differ diff --git a/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/l0_icon1.png b/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/l0_icon1.png new file mode 100644 index 00000000..fa7d60ad Binary files /dev/null and b/ios/beartracks-scout/Assets.xcassets/AppIcon.appiconset/l0_icon1.png differ diff --git a/ios/beartracks-scout/EndView.swift b/ios/beartracks-scout/EndView.swift index 3d36409e..157f75eb 100644 --- a/ios/beartracks-scout/EndView.swift +++ b/ios/beartracks-scout/EndView.swift @@ -8,7 +8,7 @@ import SwiftUI struct EndView: View { - @ObservedObject var controller: ScoutingController + @EnvironmentObject var controller: ScoutingController @State private var scoreTrap: Bool = false @State private var chainClimb: Bool = false @State private var buddyClimb: Bool = false @@ -16,13 +16,6 @@ struct EndView: View { @State private var driving: String = "" @State private var overall: String = "" - init(controller: ScoutingController) { - self.controller = controller - self.defense = controller.getDefenseResponse() - self.driving = controller.getDrivingResponse() - self.overall = controller.getOverallResponse() - } - func loadPane() { self.defense = controller.getDefenseResponse() self.driving = controller.getDrivingResponse() @@ -96,7 +89,7 @@ struct EndView: View { Spacer() } .onAppear() { - if controller.getTeamNumber() == "" || controller.getMatchNumber() == "" { + if controller.getTeamNumber() == "--" || controller.getMatchNumber() == "--" { controller.advanceToTab(tab: .start) } else { loadPane() @@ -108,5 +101,5 @@ struct EndView: View { } #Preview { - EndView(controller: ScoutingController()) + EndView() } diff --git a/ios/beartracks-scout/GameView.swift b/ios/beartracks-scout/GameView.swift index fc4d87c6..0edaa7a1 100644 --- a/ios/beartracks-scout/GameView.swift +++ b/ios/beartracks-scout/GameView.swift @@ -8,7 +8,7 @@ import SwiftUI struct GameView: View { - @ObservedObject var controller: ScoutingController + @EnvironmentObject var controller: ScoutingController var body: some View { VStack { @@ -82,12 +82,13 @@ struct GameView: View { .buttonStyle(.bordered) Text("match \(controller.getMatchNumber()) • team \(controller.getTeamNumber())") .frame(maxWidth: .infinity, alignment: .center) + .padding(.top) } .navigationTitle("Match Scouting") } } .onAppear() { - if controller.getTeamNumber() == "" || controller.getMatchNumber() == "" { + if controller.getTeamNumber() == "--" || controller.getMatchNumber() == "--" { controller.advanceToTab(tab: .start) } } @@ -95,5 +96,5 @@ struct GameView: View { } #Preview { - GameView(controller: ScoutingController()) + GameView() } diff --git a/ios/beartracks-scout/LoginView.swift b/ios/beartracks-scout/LoginView.swift index 4140197e..a4df9e1f 100644 --- a/ios/beartracks-scout/LoginView.swift +++ b/ios/beartracks-scout/LoginView.swift @@ -9,32 +9,91 @@ import SwiftUI struct LoginView: View { @State private var showAlert = false - @State private var username = "" - @State private var password = "" + @State private var authData: [String] = ["", "", "", ""] @State private var alertMessage = "" - @Environment(\.dismiss) var dismiss + @State private var loading = false + @State private var create = false + @EnvironmentObject var controller: ScoutingController var body: some View { VStack { Text("bearTracks") .font(.title) - Text("v5.0.0 • 2024") - TextField("Username", text: $username) - .padding() - .textFieldStyle(RoundedBorderTextFieldStyle()) - SecureField("Password", text: $password) - .padding() - .textFieldStyle(RoundedBorderTextFieldStyle()) - Button("Login") { - login() + Text("v5.0.3 • 2024") + if !loading { + if !create { + Text("log in") + .font(.title3) + .padding(.top) + TextField("username", text: $authData[0]) + .padding([.leading, .trailing, .bottom]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .textContentType(.username) + SecureField("password", text: $authData[1]) + .padding() + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .textContentType(.password) + Button("login") { + authAction(type: "login", data: ["username": authData[0], "password": authData[1]]) + } + .padding() + .font(.title3) + .buttonStyle(.bordered) + Button("create") { + self.create = true + } + } else { + Text("create account") + .font(.title3) + .padding(.top) + TextField("team code", text: $authData[3]) + .padding([.leading, .trailing]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .keyboardType(.numberPad) + .onChange(of: authData[3]) { _ in + authData[3] = String(authData[3].prefix(5)) + } + TextField("full name", text: $authData[2]) + .padding([.leading, .trailing]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .textContentType(.name) + TextField("username", text: $authData[0]) + .padding([.leading, .trailing]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .textContentType(.username) + SecureField("password", text: $authData[1]) + .padding([.leading, .trailing]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .textContentType(.newPassword) + Button("create") { + authAction(type: "create", data: ["access": authData[3], "full_name": authData[2], "username": authData[0], "password": authData[1]]) + } + .padding() + .font(.title3) + .buttonStyle(.bordered) + Button("login") { + self.create = false + } + } + } else { + Spacer() + ProgressView() + .controlSize(.large) + .padding() + Spacer() } - .padding() - .font(.title3) - .buttonStyle(.bordered) } .padding() .alert(isPresented: $showAlert, content: { - Alert ( + Alert( title: Text("Auth Error"), message: Text(alertMessage), dismissButton: .default(Text("ok")) @@ -42,38 +101,40 @@ struct LoginView: View { }) } - private func login() { - guard let url = URL(string: "https://beartracks.io/api/v1/auth/login") else { - return - } - - let credentials = ["username": username, "password": password] - + private func authAction(type: String, data: Dictionary) { + loading = true + guard let url = URL(string: "https://beartracks.io/api/v1/auth/\(type)") else { return } do { - let jsonData = try JSONSerialization.data(withJSONObject: credentials) - + let jsonData = try JSONSerialization.data(withJSONObject: data) var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = jsonData request.httpShouldHandleCookies = true - - sharedSession.dataTask(with: request) { _data, response, error in - if let _data = _data { + sharedSession.dataTask(with: request) { data, response, error in + if data != nil { if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode == 200 { - dismiss() + controller.loginRequired = false + loading = false } else { + loading = false showAlert = true - alertMessage = "bad credentials" + if type == "login" { + alertMessage = "bad credentials" + } else { + alertMessage = "creation failed" + } } } } else { + loading = false showAlert = true alertMessage = "network error" } }.resume() } catch { + loading = false showAlert = true alertMessage = "failed to serialize auth object" } diff --git a/ios/beartracks-scout/ReviewView.swift b/ios/beartracks-scout/ReviewView.swift index 03ff4483..46fbe089 100644 --- a/ios/beartracks-scout/ReviewView.swift +++ b/ios/beartracks-scout/ReviewView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ReviewView: View { - @ObservedObject var controller: ScoutingController + @EnvironmentObject var controller: ScoutingController @State private var submitSheetState: SubmitSheetType = .waiting @State private var showSheet: Bool = false @@ -16,12 +16,11 @@ struct ReviewView: View { VStack { NavigationStack { VStack { + Text("Match \(controller.getMatchNumber()) • Team \(controller.getTeamNumber())") + .padding(.leading) + .frame(maxWidth: .infinity, alignment: .leading) ScrollView { LazyVStack { - Text("Match \(controller.getMatchNumber()) • Team \(controller.getTeamNumber())") - .font(.caption) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) Text("Cycles") .font(.title2) .padding() @@ -94,7 +93,6 @@ struct ReviewView: View { .padding() } } - Button("submit") { showSheet = true controller.submitData { result in @@ -153,7 +151,7 @@ struct ReviewView: View { } } .onAppear() { - if controller.getTeamNumber() == "" || controller.getMatchNumber() == "" { + if controller.getTeamNumber() == "--" || controller.getMatchNumber() == "--" { controller.advanceToTab(tab: .start) } else { if controller.getDefenseResponse() == "" || controller.getDrivingResponse() == "" || controller.getOverallResponse() == "" { @@ -168,5 +166,5 @@ struct ReviewView: View { } #Preview { - ReviewView(controller: ScoutingController()) + ReviewView() } diff --git a/ios/beartracks-scout/ScoutingController.swift b/ios/beartracks-scout/ScoutingController.swift index c3f55206..97559520 100644 --- a/ios/beartracks-scout/ScoutingController.swift +++ b/ios/beartracks-scout/ScoutingController.swift @@ -9,12 +9,15 @@ import Foundation import UIKit class ScoutingController: ObservableObject { + // login state + @Published public var loginRequired: Bool = false // tab selection @Published public var currentTab: Tab = .start // basic meta private var eventCode: String = UserDefaults.standard.string(forKey: "eventCode") ?? "CAFR" - private var matchNumber: String = "" - private var teamNumber: String = "" + @Published public var matchNumber: String = "--" + @Published public var teamNumber: String = "--" + @Published public var matchList: [MatchData] = [] // match buttons @Published private(set) var times: [Double] = [0, 0, 0] private var startMillis: [Double] = [0, 0, 0] @@ -32,68 +35,27 @@ class ScoutingController: ObservableObject { private var submitSheetDetails: String = "" // getters - - func getMatchNumber() -> String { - return self.matchNumber - } - - func getTeamNumber() -> String { - return self.teamNumber - } - - func getDefenseResponse() -> String { - return self.defense - } - - func getDrivingResponse() -> String { - return self.driving - } - - func getOverallResponse() -> String { - return self.overall - } - - func getMatchTimes() -> [MatchTime] { - return self.matchTimes - } - - func getSubmitSheetMessage() -> String { - return self.submitSheetMessage - } - - func getSubmitSheetDetails() -> String { - return self.submitSheetDetails - } - + func getMatchNumber() -> String { return self.matchNumber } + func getTeamNumber() -> String { return self.teamNumber } + func getDefenseResponse() -> String { return self.defense } + func getDrivingResponse() -> String { return self.driving } + func getOverallResponse() -> String { return self.overall } + func getMatchTimes() -> [MatchTime] { return self.matchTimes } + func getSubmitSheetMessage() -> String { return self.submitSheetMessage } + func getSubmitSheetDetails() -> String { return self.submitSheetDetails } + // setters - - func setMatchNumber(match: String) { - self.matchNumber = match - } - - func setTeamNumber(team: String) { - self.teamNumber = team - } - - func setDefenseResponse(response: String) { - self.defense = response - } - - func setDrivingResponse(response: String) { - self.driving = response - } - - func setOverallResponse(response: String) { - self.overall = response - } + func setMatchNumber(match: String) { self.matchNumber = match } + func setTeamNumber(team: String) { self.teamNumber = team } + func setDefenseResponse(response: String) { self.defense = response } + func setDrivingResponse(response: String) { self.driving = response } + func setOverallResponse(response: String) { self.overall = response } // functional functions - func advanceToTab(tab: Tab) { - currentTab = tab - } + func advanceToTab(tab: Tab) { currentTab = tab } func advanceToGame() { - if matchNumber != "" && teamNumber != "" && currentTab == .start { + if matchNumber != "--" && teamNumber != "--" && currentTab == .start { currentTab = .game } } @@ -139,18 +101,14 @@ class ScoutingController: ObservableObject { } func submitData(completionBlock: @escaping (SubmitSheetType) -> Void) { - guard let url = URL(string: "https://beartracks.io/api/v1/data/submit") else { - return - } - + guard let url = URL(string: "https://beartracks.io/api/v1/data/submit") else { return } var encodedMatchTimes: String = "" do { encodedMatchTimes = try String(data: JSONEncoder().encode(matchTimes), encoding: .utf8) ?? "" } catch { - print("serialization error") + encodedMatchTimes = "" } let matchData = ScoutingDataExport(season: 2024, event: eventCode, match_num: Int(matchNumber) ?? 0, level: "Qualification", team: Int(teamNumber) ?? 0, game: encodedMatchTimes, defend: defense, driving: driving, overall: overall) - do { let jsonData = try JSONEncoder().encode(matchData) var request = URLRequest(url: url) @@ -165,15 +123,12 @@ class ScoutingController: ObservableObject { if httpResponse.statusCode == 200 { completionBlock(.done) } else { - print("server non-successful response") completionBlock(.error) } } else { - print("parse error") completionBlock(.error) } } else { - print("fetch error: \(String(describing: error))") completionBlock(.error) } } @@ -181,12 +136,35 @@ class ScoutingController: ObservableObject { } catch { completionBlock(.error) } - + } + + func getMatches(completionBlock: @escaping ([MatchData]) -> Void) { + guard let url = URL(string: "https://beartracks.io/api/v1/events/matches/\(UserDefaults.standard.string(forKey: "season") ?? "2024")/\(UserDefaults.standard.string(forKey: "eventCode") ?? "CAFR")/qualification/true") else { return } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.httpShouldHandleCookies = true + let requestTask = sharedSession.dataTask(with: request) { + (data: Data?, response: URLResponse?, error: Error?) in + if let data = data { + do { + let decoder = JSONDecoder() + let result = try decoder.decode(MatchData.self, from: data) + DispatchQueue.main.async { + completionBlock([result]) + } + } catch { + completionBlock([]) + } + } else { + completionBlock([]) + } + } + requestTask.resume() } func resetControllerData() { - self.matchNumber = "" - self.teamNumber = "" + self.matchNumber = "--" + self.teamNumber = "--" self.times = [0, 0, 0] self.startMillis = [0, 0, 0] self.buttonPressed = [false, false, false] @@ -220,3 +198,22 @@ struct MatchTime: Codable { let travel: Double let outtake: Double } + +struct MatchData: Codable { + let Schedule: [Match] +} + +struct Match: Codable { + let description: String + let startTime: String + let matchNumber: Int + let field: String + let tournamentLevel: String + let teams: [Team] +} + +struct Team: Codable { + let teamNumber: Int + let station: String + let surrogate: Bool +} diff --git a/ios/beartracks-scout/SettingsView.swift b/ios/beartracks-scout/SettingsView.swift index 35bc639a..58d8544b 100644 --- a/ios/beartracks-scout/SettingsView.swift +++ b/ios/beartracks-scout/SettingsView.swift @@ -13,13 +13,13 @@ struct SettingsView: View { @State private var darkMode: Bool = UserDefaults.standard.bool(forKey: "darkMode") @State private var showAlert = false @State private var settingsOptions: [DataMetadata] = [] - @Binding var loginRequired: Bool + @EnvironmentObject var controller: ScoutingController var body: some View { NavigationStack { - ScrollView { - VStack { - HStack { + VStack { + Form { + Section { Picker("Event Code", selection: $eventCodeInput) { if !settingsOptions.isEmpty { ForEach(settingsOptions[0].events, id: \.self) { event_code in @@ -32,14 +32,12 @@ struct SettingsView: View { } } .pickerStyle(.menu) - .padding() - Spacer() - Button("Save") { + .onChange(of: eventCodeInput) { _ in saveEventCode() + controller.getMatches { result in + controller.matchList = result + } } - .padding() - } - HStack { Picker("Season", selection: $seasonInput) { if !settingsOptions.isEmpty { ForEach(settingsOptions[0].seasons, id: \.self) { season in @@ -52,34 +50,37 @@ struct SettingsView: View { } } .pickerStyle(.menu) - .padding() - Spacer() - Button("Save") { + .onChange(of: seasonInput) { _ in saveSeason() + controller.getMatches { result in + controller.matchList = result + } } - .padding() + Toggle("Dark Mode", isOn: $darkMode) + .onChange(of: darkMode) { _ in + UserDefaults.standard.set(darkMode, forKey: "darkMode") + showAlert = true + } } - HStack { - Toggle(isOn: $darkMode) { - Label("Dark Mode", systemImage: "moon.fill") + Section { + Button("Clear Cache") { + URLCache.shared.removeAllCachedResponses() } - .padding() - .onChange(of: darkMode) { _ in - UserDefaults.standard.set(darkMode, forKey: "darkMode") - showAlert = true - } - } - HStack { Button("Log Out") { if let cookies = HTTPCookieStorage.shared.cookies(for: sharedSession.configuration.urlCache?.cachedResponse(for: URLRequest(url: URL(string: "https://beartracks.io")!))?.response.url ?? URL(string: "https://beartracks.io")!) { for cookie in cookies { sharedSession.configuration.httpCookieStorage?.deleteCookie(cookie) } - loginRequired = true + controller.loginRequired = true } } .foregroundStyle(Color.pink) - .buttonStyle(.bordered) + } + Section { + Text("This application is used to submit scouting data to bearTracks. To view this data, install the [data viewer](https://apps.apple.com/us/app/beartracks-data/id6475752596). The data viewer may only be used by users registered with a team (i.e. team code was not 00000). Team registration is free- to register your team or add yourself to a team after account creation, send an email to [admin@beartracks.io](mailto:admin@beartracks.io).") + .font(.footnote) + Text("To delete or update account information, please email [admin@beartracks.io](mailto:admin@beartracks.io).") + .font(.footnote) } } } @@ -108,14 +109,10 @@ struct SettingsView: View { } func loadSettingsJson(completionBlock: @escaping ([DataMetadata]) -> Void) -> Void { - guard let url = URL(string: "https://beartracks.io/api/v1/data") else { - return - } - + guard let url = URL(string: "https://beartracks.io/api/v1/data") else { return } var request = URLRequest(url: url) request.httpMethod = "GET" request.httpShouldHandleCookies = true - let requestTask = sharedSession.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in if let data = data { @@ -141,7 +138,7 @@ struct SettingsView: View { struct SettingsView_Preview: PreviewProvider { @State static var loginReq = false static var previews: some View { - SettingsView(loginRequired: $loginReq) + SettingsView() } } diff --git a/ios/beartracks-scout/StartView.swift b/ios/beartracks-scout/StartView.swift index 55110166..002d7365 100644 --- a/ios/beartracks-scout/StartView.swift +++ b/ios/beartracks-scout/StartView.swift @@ -8,68 +8,59 @@ import SwiftUI struct StartView: View { - @ObservedObject var controller: ScoutingController - @State private var matchNumber: String = "" - @State private var teamNumber: String = "" + @EnvironmentObject var controller: ScoutingController @FocusState private var focusField: Bool - func loadPane() { - self.matchNumber = controller.getMatchNumber() - self.teamNumber = controller.getTeamNumber() - } - var body: some View { VStack { NavigationStack { VStack { - LazyVStack { - Text("Match Number") - .padding([.leading, .top]) - .frame(maxWidth: .infinity, alignment: .leading) - TextField("required", text: $matchNumber) - .focused($focusField) - .submitLabel(.done) - .keyboardType(.numberPad) - .toolbar { - ToolbarItem(placement: .keyboard) { - HStack { - Spacer() - Button("Done") { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),to: nil, from: nil, for: nil) - } + Form { + Section { + Text("\(UserDefaults.standard.string(forKey: "eventCode") ?? "CAFR") (\(UserDefaults.standard.string(forKey: "season") ?? "2024"))") + } + Section { + Picker("Match Number", selection: $controller.matchNumber) { + Text("SELECT") + .tag("--") + .disabled(true) + if !controller.matchList.isEmpty && !controller.matchList[0].Schedule.isEmpty { + ForEach(0...controller.matchList[0].Schedule.count, id: \.self) { id in + Text(String(id + 1)) + .tag(String(id + 1)) + } + } + } + .pickerStyle(.menu) + .onChange(of: controller.matchNumber) { _ in + controller.teamNumber = "--" + } + Picker("Team Number", selection: $controller.teamNumber) { + Text("SELECT") + .tag("--") + .disabled(true) + if !controller.matchList.isEmpty && !controller.matchList[0].Schedule.isEmpty { + ForEach(controller.matchList[0].Schedule[(Int(controller.matchNumber) ?? 1) - 1].teams, id: \.teamNumber) { team_entry in + Text(String(team_entry.teamNumber)) + .tag(String(team_entry.teamNumber)) } } } - .textFieldStyle(RoundedBorderTextFieldStyle()) - .padding([.leading, .trailing]) - - Text("Team Number") - .padding([.leading, .top]) - .frame(maxWidth: .infinity, alignment: .leading) - TextField("required", text: $teamNumber) - .focused($focusField) - .submitLabel(.done) - .keyboardType(.numberPad) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .padding([.leading, .trailing, .bottom]) - - Button("continue") { - controller.setMatchNumber(match: matchNumber) - controller.setTeamNumber(team: teamNumber) - controller.advanceToGame() + .pickerStyle(.menu) + } + Section { + Button("continue") { + controller.advanceToGame() + } } - .padding() - .buttonStyle(.bordered) } } .navigationTitle("Match Scouting") } } - .onAppear() { - loadPane() - } } } #Preview { - StartView(controller: ScoutingController()) + StartView() } diff --git a/ios/beartracks-scout/SubmitSheetView.swift b/ios/beartracks-scout/SubmitSheetView.swift deleted file mode 100644 index a881b993..00000000 --- a/ios/beartracks-scout/SubmitSheetView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// SubmitSheetView.swift -// beartracks-scout -// -// Created by Jayen Agrawal on 1/14/24. -// - -import SwiftUI - -struct SubmitSheetView: View { - @StateObject var controller: ScoutingController - - var body: some View { - switch controller.submitSheetType { - case .waiting: - VStack { - Spacer() - if #available(iOS 17.0, *) { - ProgressView() - .controlSize(.extraLarge) - .padding() - } else { - ProgressView() - .controlSize(.large) - .padding() - } - Text("submitting...") - .font(.title) - Spacer() - } - case .done: - VStack { - Spacer() - Label("done", systemImage: "checkmark.seal.fill") - .labelStyle(.iconOnly) - .font(.largeTitle) - .foregroundStyle(Color.green) - .padding() - Text("done") - .font(.title) - Spacer() - } - case .error: - VStack { - Spacer() - Label("done", systemImage: "xmark.seal.fill") - .labelStyle(.iconOnly) - .font(.largeTitle) - .foregroundStyle(Color.red) - .padding() - Text("error") - .font(.title) - Spacer() - } - } - } -} - -#Preview { - SubmitSheetView(controller: ScoutingController()) -} diff --git a/ios/beartracks-scout/TabBar.swift b/ios/beartracks-scout/TabBar.swift deleted file mode 100644 index f73399e0..00000000 --- a/ios/beartracks-scout/TabBar.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// TabBar.swift -// beartracks-scout -// -// Created by Jayen Agrawal on 1/14/24. -// - -import SwiftUI - -struct TabBar: View { - @State private var loginRequired: Bool = false - @ObservedObject var scoutFormController: ScoutingController = ScoutingController() - - var body: some View { - TabView(selection: $scoutFormController.currentTab) { - StartView(controller: scoutFormController) - .tabItem { - Label("start", systemImage: "backward.end") - } - .tag(Tab.start) - - GameView(controller: scoutFormController) - .tabItem { - Label("game", systemImage: "gamecontroller") - } - .tag(Tab.game) - - EndView(controller: scoutFormController) - .tabItem { - Label("end", systemImage: "forward.end") - } - .tag(Tab.end) - - ReviewView(controller: scoutFormController) - .tabItem { - Label("review", systemImage: "magnifyingglass") - } - .tag(Tab.review) - - SettingsView(loginRequired: $loginRequired) - .tabItem { - Label("settings", systemImage: "gear") - } - .tag(Tab.settings) - } - .onAppear() { - checkLoginState { isLoggedIn in - loginRequired = !isLoggedIn - } - } - .sheet(isPresented: $loginRequired, onDismiss: { - loginRequired = false - checkLoginState { isLoggedIn in - loginRequired = !isLoggedIn - } - }) { - LoginView() - } - } -} - -#Preview { - TabBar() -} - -enum Tab { - case start, game, end, review, settings -} diff --git a/ios/beartracks-scout/beartracks-scout.entitlements b/ios/beartracks-scout/beartracks-scout.entitlements new file mode 100644 index 00000000..82b9d1d9 --- /dev/null +++ b/ios/beartracks-scout/beartracks-scout.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.associated-domains + + webcredentials:beartracks.io + + + diff --git a/ios/beartracks-scout/beartracks_scoutApp.swift b/ios/beartracks-scout/beartracks_scoutApp.swift index 0d85b76e..8da3a814 100644 --- a/ios/beartracks-scout/beartracks_scoutApp.swift +++ b/ios/beartracks-scout/beartracks_scoutApp.swift @@ -7,15 +7,66 @@ import SwiftUI +public enum Tab { + case start, game, end, review, settings +} + @main struct beartracks_scoutApp: App { let settingsManager = SettingsManager.shared var darkMode: Bool = UserDefaults.standard.bool(forKey: "darkMode") + @StateObject var scoutFormController: ScoutingController = ScoutingController() var body: some Scene { WindowGroup { - TabBar() + if !scoutFormController.loginRequired { + TabView(selection: $scoutFormController.currentTab) { + StartView() + .environmentObject(scoutFormController) + .tabItem { + Label("start", systemImage: "backward.end") + } + .tag(Tab.start) + GameView() + .environmentObject(scoutFormController) + .tabItem { + Label("game", systemImage: "gamecontroller") + } + .tag(Tab.game) + EndView() + .environmentObject(scoutFormController) + .tabItem { + Label("end", systemImage: "forward.end") + } + .tag(Tab.end) + ReviewView() + .environmentObject(scoutFormController) + .tabItem { + Label("review", systemImage: "magnifyingglass") + } + .tag(Tab.review) + SettingsView() + .environmentObject(scoutFormController) + .tabItem { + Label("settings", systemImage: "gear") + } + .tag(Tab.settings) + } .preferredColorScheme(darkMode ? .dark : .light) + .onAppear() { + checkLoginState { isLoggedIn in + scoutFormController.loginRequired = !isLoggedIn + } + scoutFormController.getMatches { result in + scoutFormController.matchList = result + } + } + .environmentObject(scoutFormController) + } else { + LoginView() + .preferredColorScheme(darkMode ? .dark : .light) + .environmentObject(scoutFormController) + } } } } diff --git a/ios/beartracks/AppState.swift b/ios/beartracks/AppState.swift new file mode 100644 index 00000000..48d3ea93 --- /dev/null +++ b/ios/beartracks/AppState.swift @@ -0,0 +1,27 @@ +// +// AppState.swift +// bearTracks +// +// Created by Jayen Agrawal on 2/7/24. +// + +import Foundation +import Combine + +class AppState: ObservableObject { +#if targetEnvironment(macCatalyst) + @Published public var selectedTab: Tab? = .teams +#else + @Published public var selectedTab: Tab = .teams +#endif + @Published public var loginRequired: Bool = false + + private var cancellables: Set = [] + + init() { + $selectedTab + .receive(on: DispatchQueue.main) + .sink { _ in } + .store(in: &cancellables) + } +} diff --git a/ios/beartracks/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/beartracks/Assets.xcassets/AppIcon.appiconset/Contents.json index 17505af7..17435e7d 100644 --- a/ios/beartracks/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/beartracks/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "IMG_0804-2-2.jpeg", + "filename" : "l0_icon2.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/ios/beartracks/Assets.xcassets/AppIcon.appiconset/IMG_0804-2-2.jpeg b/ios/beartracks/Assets.xcassets/AppIcon.appiconset/IMG_0804-2-2.jpeg deleted file mode 100644 index 4bc7c942..00000000 Binary files a/ios/beartracks/Assets.xcassets/AppIcon.appiconset/IMG_0804-2-2.jpeg and /dev/null differ diff --git a/ios/beartracks/Assets.xcassets/AppIcon.appiconset/l0_icon2.png b/ios/beartracks/Assets.xcassets/AppIcon.appiconset/l0_icon2.png new file mode 100644 index 00000000..82da01b7 Binary files /dev/null and b/ios/beartracks/Assets.xcassets/AppIcon.appiconset/l0_icon2.png differ diff --git a/ios/beartracks/Info.plist b/ios/beartracks/Info.plist index e3715e4a..47bb94e2 100644 --- a/ios/beartracks/Info.plist +++ b/ios/beartracks/Info.plist @@ -2,10 +2,8 @@ - LSApplicationCategoryType - public.app-category.productivity - NSHumanReadableCopyright - Copyright © 2024 Jayen Agrawal. All rights reserved. + ITSAppUsesNonExemptEncryption + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/beartracks/LoginStateValidator.swift b/ios/beartracks/LoginStateValidator.swift index 4cdd4db8..42adaa63 100644 --- a/ios/beartracks/LoginStateValidator.swift +++ b/ios/beartracks/LoginStateValidator.swift @@ -9,14 +9,9 @@ import Foundation /// Ensures the user has a valid session. func checkLoginState(completion: @escaping (Bool) -> Void) { - guard let url = URL(string: "https://beartracks.io/api/v1/whoami") else { - completion(false) - return - } - + guard let url = URL(string: "https://beartracks.io/api/v1/whoami") else { completion(false); return } var request = URLRequest(url: url) request.httpMethod = "GET" - let task = sharedSession.dataTask(with: request) { (data, response, error) in if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode == 200 { @@ -28,6 +23,5 @@ func checkLoginState(completion: @escaping (Bool) -> Void) { completion(false) } } - task.resume() } diff --git a/ios/beartracks/LoginView.swift b/ios/beartracks/LoginView.swift index 121567d9..8f5adb1d 100644 --- a/ios/beartracks/LoginView.swift +++ b/ios/beartracks/LoginView.swift @@ -6,41 +6,95 @@ // import SwiftUI -import SafariServices /// Login sheet view struct LoginView: View { @State private var showAlert = false - @State private var username = "" - @State private var password = "" + @State private var authData: [String] = ["", "", "", ""] @State private var alertMessage = "" - @Environment(\.dismiss) var dismiss - @Environment(\.presentationMode) var presentationMode + @State private var loading = false + @State private var create = false + @EnvironmentObject var appState: AppState var body: some View { VStack { Text("bearTracks") .font(.title) - Text("v5.0.1 • 2024") - - TextField("Username", text: $username) - .padding() - .textFieldStyle(RoundedBorderTextFieldStyle()) - - SecureField("Password", text: $password) - .padding() - .textFieldStyle(RoundedBorderTextFieldStyle()) - - Button("Login") { - login() + Text("v5.0.3 • 2024") + if !loading { + if !create { + Text("log in") + .font(.title3) + .padding(.top) + TextField("username", text: $authData[0]) + .padding([.leading, .trailing, .bottom]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .textContentType(.username) + SecureField("password", text: $authData[1]) + .padding() + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .textContentType(.password) + Button("login") { + authAction(type: "login", data: ["username": authData[0], "password": authData[1]]) + } + .padding() + .font(.title3) + .buttonStyle(.bordered) + Button("create") { + self.create = true + } + } else { + Text("create account") + .font(.title3) + .padding(.top) + TextField("team code", text: $authData[3]) + .padding([.leading, .trailing]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .keyboardType(.numberPad) + .onChange(of: authData[3]) { _ in + authData[3] = String(authData[3].prefix(5)) + } + TextField("full name", text: $authData[2]) + .padding([.leading, .trailing]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .textContentType(.name) + TextField("username", text: $authData[0]) + .padding([.leading, .trailing]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .textContentType(.username) + SecureField("password", text: $authData[1]) + .padding([.leading, .trailing]) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .textContentType(.newPassword) + Button("create") { + authAction(type: "create", data: ["access": authData[3], "full_name": authData[2], "username": authData[0], "password": authData[1]]) + } + .padding() + .font(.title3) + .buttonStyle(.bordered) + Button("login") { + self.create = false + } + } + } else { + Spacer() + ProgressView() + .controlSize(.large) + .padding() + Spacer() } - .padding() - .font(.title3) - .buttonStyle(.bordered) } .padding() .alert(isPresented: $showAlert, content: { - Alert ( + Alert( title: Text("Auth Error"), message: Text(alertMessage), dismissButton: .default(Text("ok")) @@ -48,38 +102,40 @@ struct LoginView: View { }) } - private func login() { - guard let url = URL(string: "https://beartracks.io/api/v1/auth/login") else { - return - } - - let credentials = ["username": username, "password": password] - + private func authAction(type: String, data: Dictionary) { + loading = true + guard let url = URL(string: "https://beartracks.io/api/v1/auth/\(type)") else { return } do { - let jsonData = try JSONSerialization.data(withJSONObject: credentials) - + let jsonData = try JSONSerialization.data(withJSONObject: data) var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = jsonData request.httpShouldHandleCookies = true - - sharedSession.dataTask(with: request) { _data, response, error in - if let _data = _data { + sharedSession.dataTask(with: request) { data, response, error in + if data != nil { if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode == 200 { - dismiss() + appState.loginRequired = false + loading = false } else { + loading = false showAlert = true - alertMessage = "bad credentials" + if type == "login" { + alertMessage = "bad credentials" + } else { + alertMessage = "creation failed" + } } } } else { + loading = false showAlert = true alertMessage = "network error" } }.resume() } catch { + loading = false showAlert = true alertMessage = "failed to serialize auth object" } diff --git a/ios/beartracks/MatchList.swift b/ios/beartracks/MatchList.swift index 48aa9510..bc30db98 100644 --- a/ios/beartracks/MatchList.swift +++ b/ios/beartracks/MatchList.swift @@ -16,7 +16,6 @@ struct MatchList: View { @State private var loadComplete: Bool = false @State private var selectedItem: String = "" - var body: some View { VStack { NavigationStack { @@ -93,6 +92,7 @@ struct MatchList: View { .navigationTitle("Matches") .navigationDestination(isPresented: $showSheet) { TeamView(team: selectedItem) + .navigationTitle("team \(selectedItem)") } } else { if loadFailed { diff --git a/ios/beartracks/SettingsView.swift b/ios/beartracks/SettingsView.swift index fd76892a..9c288994 100644 --- a/ios/beartracks/SettingsView.swift +++ b/ios/beartracks/SettingsView.swift @@ -15,13 +15,13 @@ struct SettingsView: View { @State private var darkMode: Bool = UserDefaults.standard.bool(forKey: "darkMode") @State private var showAlert = false @State private var settingsOptions: [DataMetadata] = [] - @Binding var loginRequired: Bool + @EnvironmentObject var appState: AppState var body: some View { NavigationStack { VStack { - VStack { - HStack { + Form { + Section { Picker("Team Number", selection: $teamNumberInput) { if !settingsOptions.isEmpty { ForEach(settingsOptions[0].teams, id: \.self) { team in @@ -33,15 +33,10 @@ struct SettingsView: View { .tag(teamNumberInput) } } - .padding() .pickerStyle(.menu) - Spacer() - Button("Save") { - saveTeamNumber() + .onChange(of: teamNumberInput) { value in + UserDefaults.standard.set(teamNumberInput, forKey: "teamNumber") } - .padding() - } - HStack { Picker("Event Code", selection: $eventCodeInput) { if !settingsOptions.isEmpty { ForEach(settingsOptions[0].events, id: \.self) { event_code in @@ -54,14 +49,9 @@ struct SettingsView: View { } } .pickerStyle(.menu) - .padding() - Spacer() - Button("Save") { - saveEventCode() + .onChange(of: eventCodeInput) { value in + UserDefaults.standard.set(eventCodeInput, forKey: "eventCode") } - .padding() - } - HStack { Picker("Season", selection: $seasonInput) { if !settingsOptions.isEmpty { ForEach(settingsOptions[0].seasons, id: \.self) { season in @@ -74,35 +64,31 @@ struct SettingsView: View { } } .pickerStyle(.menu) - .padding() - Spacer() - Button("Save") { - saveSeason() - } - .padding() - } - HStack { - Toggle(isOn: $darkMode) { - Label("Dark Mode", systemImage: "moon.fill") + .onChange(of: seasonInput) { value in + UserDefaults.standard.set(seasonInput, forKey: "season") } - .padding() - .onChange(of: darkMode) { + Toggle("Dark Mode", isOn: $darkMode) + .onChange(of: darkMode) { value in UserDefaults.standard.set(darkMode, forKey: "darkMode") showAlert = true } } - HStack { + Section { + Button("Clear Cache") { + URLCache.shared.removeAllCachedResponses() + } Button("Log Out") { if let cookies = HTTPCookieStorage.shared.cookies(for: sharedSession.configuration.urlCache?.cachedResponse(for: URLRequest(url: URL(string: "https://beartracks.io")!))?.response.url ?? URL(string: "https://beartracks.io")!) { for cookie in cookies { sharedSession.configuration.httpCookieStorage?.deleteCookie(cookie) } - loginRequired = true + appState.loginRequired = true } } .foregroundStyle(Color.pink) - .buttonStyle(.bordered) } + Text("To delete or update account information, please email [admin@beartracks.io](mailto:admin@beartracks.io).") + .font(.footnote) } Spacer() } @@ -122,18 +108,6 @@ struct SettingsView: View { } } - func saveTeamNumber() { - UserDefaults.standard.set(teamNumberInput, forKey: "teamNumber") - } - - func saveEventCode() { - UserDefaults.standard.set(eventCodeInput, forKey: "eventCode") - } - - func saveSeason() { - UserDefaults.standard.set(seasonInput, forKey: "season") - } - func loadSettingsJson(completionBlock: @escaping ([DataMetadata]) -> Void) -> Void { guard let url = URL(string: "https://beartracks.io/api/v1/data") else { return @@ -142,7 +116,6 @@ struct SettingsView: View { var request = URLRequest(url: url) request.httpMethod = "GET" request.httpShouldHandleCookies = true - let requestTask = sharedSession.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in if let data = data { @@ -168,7 +141,7 @@ struct SettingsView: View { struct SettingsView_Preview: PreviewProvider { @State static var loginReq = false static var previews: some View { - SettingsView(loginRequired: $loginReq) + SettingsView() } } diff --git a/ios/beartracks/TabBar.swift b/ios/beartracks/TabBar.swift deleted file mode 100644 index 3cac2ec3..00000000 --- a/ios/beartracks/TabBar.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// TabBar.swift -// beartracks -// -// Created by Jayen Agrawal on 1/9/24. -// - -import SwiftUI - -/// Primary navigaion element. -/// -/// - iOS -/// - Uses `TabBar` view. All views called by the `TabBar` use `NavigationStack` -/// - Mac Catalyst -/// - Uses a `NavigationSplitView` with a standard `List` sidebar and a content area made up of `NavigationStack` views. Some of the `NavigationStack`views will call child views the manner as the iOS app. -struct TabBar: View { - enum Tab { - case teams, matches, data, settings - } - @State private var selectedTab: Tab? = .teams - @State private var loginRequired: Bool = false - - var body: some View { - #if targetEnvironment(macCatalyst) - NavigationSplitView(sidebar: { - List(selection: $selectedTab) { - NavigationLink(value: Tab.teams) { - Label("teams", systemImage: "list.number") - } - NavigationLink(value: Tab.matches) { - Label("matches", systemImage: "calendar") - } - NavigationLink(value: Tab.data) { - Label("data", systemImage: "magnifyingglass") - } - NavigationLink(value: Tab.settings) { - Label("settings", systemImage: "gear") - } - } - .navigationTitle("bearTracks") - .onAppear() { - checkLoginState { isLoggedIn in - loginRequired = !isLoggedIn - } - } - .sheet(isPresented: $loginRequired, onDismiss: { - loginRequired = false - checkLoginState { isLoggedIn in - loginRequired = !isLoggedIn - } - }) { - LoginView() - } - }, detail: { - switch selectedTab { - case .teams: - Teams() - case .matches: - MatchList() - case .data: - DataView() - case .settings: - SettingsView(loginRequired: $loginRequired) - case nil: - LoginView() - } - }) - #else - TabView(selection: $selectedTab) { - Teams() - .tabItem { - Label("teams", systemImage: "list.number") - } - .tag(Tab.teams) - MatchList() - .tabItem { - Label("matches", systemImage: "calendar") - } - .tag(Tab.matches) - DataView() - .tabItem { - Label("data", systemImage: "magnifyingglass") - } - .tag(Tab.data) - SettingsView(loginRequired: $loginRequired) - .tabItem { - Label("settings", systemImage: "gear") - } - .tag(Tab.settings) - } - .onAppear() { - checkLoginState { isLoggedIn in - loginRequired = !isLoggedIn - } - } - .sheet(isPresented: $loginRequired, onDismiss: { - loginRequired = false - checkLoginState { isLoggedIn in - loginRequired = !isLoggedIn - } - }) { - LoginView() - } - #endif - } -} - -#Preview { - TabBar() -} diff --git a/ios/beartracks/Teams.swift b/ios/beartracks/Teams.swift index 94acfc06..4d8ea940 100644 --- a/ios/beartracks/Teams.swift +++ b/ios/beartracks/Teams.swift @@ -11,9 +11,7 @@ import Foundation /// Shows listing of top teams by scouting data performance, not RPs struct Teams: View { @State private var teamsList: [TeamData] = [] - @State private var showSheet: Bool = false - @State private var loadFailed: Bool = false - @State private var loadComplete: Bool = false + @State private var loadState: (Bool, Bool, Bool) = (false, false, false) @State private var selectedItem: String = "" var body: some View { @@ -43,7 +41,7 @@ struct Teams: View { } .onTapGesture { selectedItem = String(team.team.team) - showSheet = true + loadState.0 = true } .contentShape(Rectangle()) #if targetEnvironment(macCatalyst) @@ -54,12 +52,12 @@ struct Teams: View { } } .navigationTitle("Teams") - .navigationDestination(isPresented: $showSheet) { + .navigationDestination(isPresented: $loadState.0) { TeamView(team: selectedItem) .navigationTitle("team \(selectedItem)") } } else { - if loadFailed { + if loadState.1 { VStack { Label("failed", systemImage: "xmark.seal.fill") .padding(.bottom) @@ -70,7 +68,7 @@ struct Teams: View { } .navigationTitle("Teams") } else { - if loadComplete { + if loadState.2 { VStack { Label("none", systemImage: "questionmark.app.dashed") .padding(.bottom) @@ -117,17 +115,16 @@ struct Teams: View { } } DispatchQueue.main.async { - self.loadFailed = false - self.loadComplete = true + self.loadState = (self.loadState.0, false, true) self.teamsList = [result] } } catch { print("parse error \(error)") - self.loadFailed = true + self.loadState.1 = true } } else if let error = error { print("fetch error: \(error)") - self.loadFailed = true + self.loadState.1 = true } }.resume() } diff --git a/ios/beartracks/bearTracks.entitlements b/ios/beartracks/bearTracks.entitlements index ee95ab7e..d2d71196 100644 --- a/ios/beartracks/bearTracks.entitlements +++ b/ios/beartracks/bearTracks.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.associated-domains + + webcredentials:beartracks.io + com.apple.security.app-sandbox com.apple.security.network.client diff --git a/ios/beartracks/beartracksApp.swift b/ios/beartracks/beartracksApp.swift index e1ad9e2d..277dfc9b 100644 --- a/ios/beartracks/beartracksApp.swift +++ b/ios/beartracks/beartracksApp.swift @@ -7,21 +7,96 @@ import SwiftUI +public enum Tab { + case teams, matches, data, settings +} + @main struct beartracksApp: App { let settingsManager = SettingsManager.shared var darkMode: Bool = UserDefaults.standard.bool(forKey: "darkMode") + @StateObject private var appState = AppState() var body: some Scene { WindowGroup { - TabBar() + if !appState.loginRequired { +#if targetEnvironment(macCatalyst) + NavigationSplitView(sidebar: { + List(selection: $appState.selectedTab) { + NavigationLink(value: Tab.teams) { + Label("teams", systemImage: "list.number") + } + NavigationLink(value: Tab.matches) { + Label("matches", systemImage: "calendar") + } + NavigationLink(value: Tab.data) { + Label("data", systemImage: "magnifyingglass") + } + NavigationLink(value: Tab.settings) { + Label("settings", systemImage: "gear") + } + } + .navigationTitle("bearTracks") + }, detail: { + switch appState.selectedTab { + case .teams: + Teams() + case .matches: + MatchList() + case .data: + DataView() + case .settings: + SettingsView() + .environmentObject(appState) + case nil: + LoginView() + .environmentObject(appState) + } + }) + .preferredColorScheme(darkMode ? .dark : .light) + .onAppear() { + checkLoginState { isLoggedIn in + appState.loginRequired = !isLoggedIn + } + } + .environmentObject(appState) +#else + TabView(selection: $appState.selectedTab) { + Teams() + .tabItem { + Label("teams", systemImage: "list.number") + } + .tag(Tab.teams) + MatchList() + .tabItem { + Label("matches", systemImage: "calendar") + } + .tag(Tab.matches) + DataView() + .tabItem { + Label("data", systemImage: "magnifyingglass") + } + .tag(Tab.data) + SettingsView() + .environmentObject(appState) + .tabItem { + Label("settings", systemImage: "gear") + } + .tag(Tab.settings) + } .preferredColorScheme(darkMode ? .dark : .light) + .onAppear() { + checkLoginState { isLoggedIn in + appState.loginRequired = !isLoggedIn + } + } + .environmentObject(appState) +#endif + } else { + LoginView() + .environmentObject(appState) + .preferredColorScheme(darkMode ? .dark : .light) + } } } } - -extension String { - func separate(every stride: Int = 4, with separator: Character = " ") -> String { - return String(enumerated().map { $0 > 0 && $0 % stride == 0 ? [separator, $1] : [$1]}.joined()) - } -} diff --git a/setup.sh b/setup.sh index 42f3f9ad..ff39b447 100644 --- a/setup.sh +++ b/setup.sh @@ -17,6 +17,7 @@ chmod +x update.sh # make i cp bearTracks/service.sh service.sh # copy service management script chmod +x service.sh # make it executatble mkdir ssl # create ssl directory for certificates +mkdir cache # create directory for server cache cd bearTracks # ~/bearTracks/bearTracks (git repo) curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # install rust source "$HOME/.cargo/env" # source (needed if rust is newly installed) diff --git a/src/analyze.rs b/src/analyze.rs index a01557ee..7ba73c3b 100644 --- a/src/analyze.rs +++ b/src/analyze.rs @@ -238,8 +238,8 @@ fn season_2024(data: &web::Json) -> Result) -> Result = vec!( + real_bool_to_num(trap_note) as i64, + real_bool_to_num(climb) as i64, + real_bool_to_num(buddy) as i64, + intake_time as i64, + travel_time as i64, + outtake_time as i64, + speaker_scores, + amplifier_scores, + score as i64 ); let string_mps_scores: Vec = mps_scores @@ -293,7 +305,7 @@ fn season_2024(data: &web::Json) -> Result = analysis_results + let string_analysis_results: Vec = analysis .iter() .map(|float| float.to_string()) .collect(); diff --git a/src/auth.rs b/src/auth.rs index bcaba57f..bbeeab73 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -39,14 +39,24 @@ pub async fn create_account(pool: &db_auth::Pool, create_form: web::Json, actix_web::Error> = db_auth::get_access_key(pool, create_form.access.clone(), db_auth::AccessKeyQuery::ById).await; - if access_key_temp.is_err() { - return HttpResponse::BadRequest().status(StatusCode::from_u16(400).unwrap()).insert_header(("Cache-Control", "no-cache")).body("{\"status\": \"bad_access_key\"}"); + if create_form.access != "00000" { + let access_key_temp: Result, actix_web::Error> = db_auth::get_access_key(pool, create_form.access.clone(), db_auth::AccessKeyQuery::ById).await; + if access_key_temp.is_err() { + return HttpResponse::BadRequest().status(StatusCode::from_u16(400).unwrap()).insert_header(("Cache-Control", "no-cache")).body("{\"status\": \"bad_access_key\"}"); + } else { + // insert into database + let access_key: db_auth::AccessKey = access_key_temp.unwrap().first().unwrap().clone(); + let user_temp: Result = db_auth::create_user(pool, access_key.team, html_escape::encode_text(&create_form.full_name).to_string(), html_escape::encode_text(&create_form.username).to_string(), html_escape::encode_text(&create_form.password).to_string()).await; + // send final success/failure for creation + if user_temp.is_err() { + return HttpResponse::BadRequest().status(StatusCode::from_u16(500).unwrap()).insert_header(("Cache-Control", "no-cache")).body("{\"status\": \"creation_error\"}"); + } else { + drop(user_temp); + return HttpResponse::Ok().status(StatusCode::from_u16(200).unwrap()).insert_header(("Cache-Control", "no-cache")).body("{\"status\": \"success\"}"); + } + } } else { - // insert into database - let access_key: db_auth::AccessKey = access_key_temp.unwrap().first().unwrap().clone(); - let user_temp: Result = db_auth::create_user(pool, access_key.team, html_escape::encode_text(&create_form.full_name).to_string(), html_escape::encode_text(&create_form.username).to_string(), html_escape::encode_text(&create_form.password).to_string()).await; - // send final success/failure for creation + let user_temp: Result = db_auth::create_user(pool, 0, html_escape::encode_text(&create_form.full_name).to_string(), html_escape::encode_text(&create_form.username).to_string(), html_escape::encode_text(&create_form.password).to_string()).await; if user_temp.is_err() { return HttpResponse::BadRequest().status(StatusCode::from_u16(500).unwrap()).insert_header(("Cache-Control", "no-cache")).body("{\"status\": \"creation_error\"}"); } else { diff --git a/src/db_auth.rs b/src/db_auth.rs index 01d1b9a9..0a8708df 100644 --- a/src/db_auth.rs +++ b/src/db_auth.rs @@ -72,7 +72,7 @@ pub struct User { pub current_challenge: String, pub full_name: String, pub team: i64, - pub method: String, + pub data: String, pub pass_hash: String, pub admin: String, pub team_admin: i64, @@ -124,7 +124,7 @@ fn get_user_id_entry(conn: Connection, id: String) -> Result Result Result, rusqlite::Erro pub async fn create_user(pool: &Pool, team: i64, full_name: String, username: String, password: String) -> Result { let pool = pool.clone(); - let conn = web::block(move || pool.get()) .await? .map_err(error::ErrorInternalServerError)?; - web::block(move || { let generated_salt = SaltString::generate(&mut OsRng); // argon2id v19 @@ -219,7 +217,7 @@ pub async fn create_user(pool: &Pool, team: i64, full_name: String, username: St current_challenge: "".to_string(), full_name, team, - method: "pw".to_string(), + data: "".to_string(), pass_hash: "".to_string(), admin: "false".to_string(), team_admin: 0, @@ -234,14 +232,14 @@ pub async fn create_user(pool: &Pool, team: i64, full_name: String, username: St } fn create_user_entry(conn: Connection, team: i64, full_name: String, username: String, password_hash: String) -> Result { - let mut stmt = conn.prepare("INSERT INTO users (username, current_challenge, full_name, team, method, pass_hash, admin, team_admin, access_ok, score) VALUES (?, '', ?, ?, 'pw', ?, 'false', 0, 'true', 0);")?; + let mut stmt = conn.prepare("INSERT INTO users (username, current_challenge, full_name, team, data, pass_hash, admin, team_admin, access_ok, score) VALUES (?, '', ?, ?, '', ?, 'false', 0, 'true', 0);")?; let mut new_user = User { id: 0, username, current_challenge: "".to_string(), full_name, team, - method: "pw".to_string(), + data: "".to_string(), pass_hash: password_hash, admin: "false".to_string(), team_admin: 0, @@ -264,6 +262,26 @@ pub fn update_points(conn: Connection, user_id: i64, inc: i64) -> Result Result { + let pool = pool.clone(); + + let conn = web::block(move || pool.get()) + .await? + .map_err(error::ErrorInternalServerError)?; + + web::block(move || { + update_user_data_transaction(conn, user_id, new_data) + }) + .await? + .map_err(error::ErrorInternalServerError) +} + +pub fn update_user_data_transaction(conn: Connection, user_id: i64, new_data: String) -> Result { + let mut stmt: Statement<'_> = conn.prepare("UPDATE users SET data = ?1 WHERE id = ?2;")?; + stmt.execute(params![new_data, user_id])?; + Ok(db_main::Id { id: conn.last_insert_rowid() }) +} + #[derive(Serialize, Deserialize, Clone)] pub struct UserPartial { pub id: i64, diff --git a/src/db_main.rs b/src/db_main.rs index 4a46bebc..b6622a34 100644 --- a/src/db_main.rs +++ b/src/db_main.rs @@ -243,6 +243,28 @@ fn get_id_rows(mut statement: Statement) -> QueryResult { .and_then(Iterator::collect) } +pub async fn get_team_numbers(pool: &Pool, season: String) -> Result, Error> { + // clone pool + let pool = pool.clone(); + + // get database connection + let conn = web::block(move || pool.get()) + .await? + .map_err(error::ErrorInternalServerError)?; + + // run query function based on provided enum + web::block(move || { + let mut stmt = conn.prepare("SELECT team FROM main WHERE season=?1 ORDER BY id DESC;")?; + stmt + .query_map([season], |row| { + Ok(row.get(0)?) + }) + .and_then(Iterator::collect) + }) + .await? + .map_err(error::ErrorInternalServerError) +} + // incoming data structure for a new main form submission #[derive(Deserialize)] pub struct MainInsert { diff --git a/src/forward.rs b/src/forward.rs index ab48a9af..fe376cfb 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -1,54 +1,28 @@ -use std::env; +use std::path::PathBuf; use actix_web::{web, HttpRequest, HttpResponse}; -use reqwest::Client; -// [deprecated] -pub async fn forward_frc_api_event_teams(req: HttpRequest, path: web::Path<(String, String)>) -> HttpResponse { +pub async fn forward_frc_api_event_teams(_req: HttpRequest, path: web::Path<(String, String)>) -> HttpResponse { let (season, event) = path.into_inner(); - let target_url = format!("https://frc-api.firstinspires.org/v3.0/{}/teams?eventCode={}", season, event); - let client = Client::new(); - let response = client - .request(req.method().clone(), target_url) - .header("Authorization", format!("Basic {}", env::var("FRC_API_KEY").unwrap_or_else(|_| "NONE".to_string()))) - .send() - .await; - - match response { - Ok(response) => { - HttpResponse::build(response.status()) - .insert_header(("Cache-Control", "public, max-age=46656000, immutable")) - .body(response.bytes().await.unwrap().to_vec()) - } - Err(_) => HttpResponse::InternalServerError().finish(), + let path: PathBuf = PathBuf::from(format!("cache/frc_api/{}/{}/teams.json", season, event)); + if let Ok(content) = std::fs::read_to_string(&path) { + HttpResponse::Ok() + .insert_header(("Cache-Control", "public, max-age=93312000, immutable")) // 30 days + .content_type("application/json") + .body(content) + } else { + HttpResponse::NotFound().finish() } } -pub async fn forward_frc_api_event_matches(req: HttpRequest, path: web::Path<(String, String, String, String)>) -> HttpResponse { - // generate request url - let (season, event, level, all) = path.into_inner(); - let mut url_param: String = "&start=&end=".to_string(); - if all != "all" { - if all != "false" { - url_param = format!("&teamNumber={}", all) - } else { - url_param = format!("&teamNumber={}", env::var("MY_TEAM").unwrap_or_else(|_| "766".to_string())) - } - } - let target_url: String = format!("https://frc-api.firstinspires.org/v3.0/{}/schedule/{}?tournamentLevel={}{}", season, event, level, url_param); - // create client and get response - let client: Client = Client::new(); - let response: Result = client - .request(req.method().clone(), target_url) - .header("Authorization", format!("Basic {}", env::var("FRC_API_KEY").unwrap_or_else(|_| "NONE".to_string()))) - .send() - .await; - // if ok, send. else, send a bad gateway error - match response { - Ok(response) => { - HttpResponse::build(response.status()) - .insert_header(("Cache-Control", "public, max-age=46656000, immutable")) - .body(response.bytes().await.unwrap().to_vec()) - } - Err(_) => HttpResponse::BadGateway().finish(), +pub async fn forward_frc_api_event_matches(_req: HttpRequest, path: web::Path<(String, String)>) -> HttpResponse { + let (season, event) = path.into_inner(); + let path: PathBuf = PathBuf::from(format!("cache/frc_api/{}/{}/matches.json", season, event)); + if let Ok(content) = std::fs::read_to_string(&path) { + HttpResponse::Ok() + .insert_header(("Cache-Control", "public, max-age=93312000, immutable")) // 30 days + .content_type("application/json") + .body(content) + } else { + HttpResponse::NotFound().finish() } -} \ No newline at end of file +} diff --git a/src/game_api.rs b/src/game_api.rs new file mode 100644 index 00000000..1ad9b82d --- /dev/null +++ b/src/game_api.rs @@ -0,0 +1,325 @@ +use actix_web::{error, web, Error}; +use rand::seq::SliceRandom; +use rusqlite::Statement; +use serde::{Serialize, Deserialize}; + +use crate::db_auth; +use crate::db_main; +use crate::stats; + +#[derive(Serialize)] +pub struct DataStats { + pub first: i64, + pub median: i64, + pub third: i64, + pub mean: i64, + pub decaying: i64 +} + +#[derive(Serialize, Deserialize)] +pub struct GameUserData { + cards: Vec, + hand: Vec, + wins: i64, + losses: i64, + ties: i64, + box_count: i64, +} + +#[derive(Serialize, Deserialize)] +pub struct ClientInfo { + id: i64, + username: String, + team: i64, + score: i64, + game_data: GameUserData +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FrcApiTeam { + pub team_number: i64, + pub name_full: String, + pub name_short: String, + pub city: String, + pub state_prov: String, + pub country: String, + pub rookie_year: i64, + pub robot_name: String, + pub district_code: Option, + pub school_name: String, + pub website: String, + pub home_c_m_p: String, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FrcApiTeams { + pub team_count_total: i64, + pub team_count_page: i64, + pub page_current: i64, + pub page_total: i64, + pub teams: Vec, +} + +pub async fn get_owned_cards(pool: &db_auth::Pool, user: db_auth::User) -> Result { + let user_updated = db_auth::get_user_id(pool, user.id.to_string()).await?; + if user_updated.data == "" { + db_auth::update_user_data( + pool, + user.id, + serde_json::to_string(&GameUserData { + cards: vec![99999, 99998, 99997], + hand: vec![99999, 99998, 99997], + wins: 0, + losses: 0, + ties: 0, + box_count: 0 + }).unwrap_or("".to_string()) + ).await?; + Ok(ClientInfo { + id: -1, + username: "none".to_string(), + team: -1, + score: -1, + game_data: GameUserData { cards: vec![99999, 99998, 99997], hand: vec![99999, 99998, 99997], wins: 0, losses: 0, ties: 0, box_count: 0 } + }) + } else { + Ok( + ClientInfo { + id: user_updated.id, + username: user_updated.username, + team: user_updated.team, + score: user_updated.score, + game_data: serde_json::from_str::(&user_updated.data).unwrap_or(GameUserData { cards: vec![99999, 99998, 99997], hand: vec![99999, 99998, 99997], wins: 0, losses: 0, ties: 0, box_count: 0 }) + } + ) + } +} + +pub async fn open_loot_box(auth_pool: &db_auth::Pool, main_pool: &db_main::Pool, user_param: db_auth::User) -> Result { + let user_queried = db_auth::get_user_id(auth_pool, user_param.id.to_string()).await; + if !user_queried.is_ok() { + return Ok(-1) + } + let user = user_queried.unwrap(); + let teams = db_main::get_team_numbers(main_pool, "2024".to_string()).await; + if teams.is_ok() { + let team_list = teams.unwrap(); + if team_list.is_empty() { + Ok(-1) + } else { + let card = team_list.choose(&mut rand::thread_rng()).unwrap().clone(); + if user.score >= 100 { + if user.data == "" { + db_auth::update_user_data(auth_pool, user.id, serde_json::to_string(&GameUserData { cards: vec![99999, 99998, 99997], hand: vec![99999, 99998, 99997], wins: 0, losses: 0, ties: 0, box_count: 0 }).unwrap_or("".to_string())).await?; + } + + let mut current_user_data = serde_json::from_str::(&user.data).unwrap(); + current_user_data.box_count += 1; + current_user_data.cards.push(card); + db_auth::update_user_data(auth_pool, user.id, serde_json::to_string(¤t_user_data).unwrap_or("".to_string())).await?; + + let auth_pool = auth_pool.clone(); + let auth_conn = web::block(move || auth_pool.get()) + .await? + .map_err(error::ErrorInternalServerError)?; + + db_auth::update_points(auth_conn, user.id, -100) + .map_err(error::ErrorInternalServerError)?; + + Ok(card) + } else { + Ok(-1) + } + } + } else { + Ok(-1) + } +} + +#[derive(Serialize, Deserialize)] +pub struct CardsPostData { + cards: Vec +} + +pub async fn set_held_cards(auth_pool: &db_auth::Pool, user_param: db_auth::User, data: &web::Json) -> Result { + let user_queried = db_auth::get_user_id(auth_pool, user_param.id.to_string()).await; + if !user_queried.is_ok() { + return Ok(CardsPostData { cards: vec![-1, 3] }) + } + let user = user_queried.unwrap(); + if user.data == "" { + Ok(CardsPostData { cards: vec![-1, 3] }) + } else { + let mut current_user_data = serde_json::from_str::(&user.data).unwrap(); + let mut cards_not_ok = false; + if data.cards.len() == 3 { + data.cards.iter().for_each(|card| { + if !current_user_data.cards.contains(card) { + cards_not_ok = true; + } + }); + } else { + cards_not_ok = true; + } + if !cards_not_ok { + current_user_data.hand = data.cards.clone(); + db_auth::update_user_data(auth_pool, user.id, serde_json::to_string(¤t_user_data).unwrap_or("".to_string())).await?; + return Ok(CardsPostData { cards: current_user_data.hand }) + } else { + return Ok(CardsPostData { cards: vec![-1, 3] }) + } + } +} + +// 2024 only + +#[derive(Serialize)] +pub struct Team { + pub team: i64, + pub trap_note: f64, + pub climb: f64, + pub buddy_climb: f64, + pub intake: DataStats, + pub travel: DataStats, + pub outtake: DataStats, + pub speaker: DataStats, + pub amplifier: DataStats, + pub total: DataStats, + pub points: DataStats +} + +struct TeamDataset { + trap_note: Vec, + climb: Vec, + buddy_climb: Vec, + intake: Vec, + travel: Vec, + outtake: Vec, + speaker: Vec, + amplifier: Vec, + shots: Vec, + points: Vec +} + +struct MainAnalysis { + analysis: String, +} + +pub async fn execute(pool: &db_main::Pool, season: String, event: String, team: String) -> Result { + let pool = pool.clone(); + + let conn = web::block(move || pool.get()) + .await? + .map_err(error::ErrorInternalServerError)?; + + web::block(move || { + get_team(conn, season, event, team) + }) + .await? + .map_err(error::ErrorInternalServerError) +} + +fn get_team(conn: db_main::Connection, season: String, event: String, team: String) -> Result { + let stmt = conn.prepare("SELECT analysis FROM main WHERE season=:season AND event=:event AND team=:team;")?; + get_rows(stmt, [season, event, team]) +} + +fn get_rows(mut statement: Statement, params: [String; 3]) -> Result { + let data: Vec = statement + .query_map(params.clone(), |row| { + Ok(MainAnalysis { + analysis: row.get(0)? + }) + }) + .and_then(Iterator::collect) + .unwrap(); + + let mut data_arr: TeamDataset = TeamDataset { trap_note: Vec::new(), climb: Vec::new(), buddy_climb: Vec::new(), intake: Vec::new(), travel: Vec::new(), outtake: Vec::new(), speaker: Vec::new(), amplifier: Vec::new(), points: Vec::new(), shots: Vec::new() }; + data.iter().for_each(|entry| { + let game_data: Vec = entry.analysis.split(",").map(|v| v.parse::().unwrap_or(0)).collect(); + data_arr.trap_note.push(game_data[0]); + data_arr.climb.push(game_data[1]); + data_arr.buddy_climb.push(game_data[2]); + data_arr.intake.push(game_data[3]); + data_arr.travel.push(game_data[4]); + data_arr.outtake.push(game_data[5]); + data_arr.speaker.push(game_data[6]); + data_arr.amplifier.push(game_data[7]); + data_arr.shots.push(game_data[6] + game_data[7]); + data_arr.points.push(game_data[8]); + }); + + let intake_qrt = stats::quartiles_i64(&data_arr.intake); + let travel_qrt = stats::quartiles_i64(&data_arr.travel); + let outtake_qrt = stats::quartiles_i64(&data_arr.outtake); + let speaker_qrt = stats::quartiles_i64(&data_arr.speaker); + let amplifier_qrt = stats::quartiles_i64(&data_arr.amplifier); + let total_qrt = stats::quartiles_i64(&data_arr.shots); + let points_qrt = stats::quartiles_i64(&data_arr.points); + + let intake_means = stats::means_i64(&data_arr.intake, 0.5); + let travel_means = stats::means_i64(&data_arr.travel, 0.5); + let outtake_means = stats::means_i64(&data_arr.outtake, 0.5); + let speaker_means = stats::means_i64(&data_arr.speaker, 0.5); + let amplifier_means = stats::means_i64(&data_arr.amplifier, 0.5); + let total_means = stats::means_i64(&data_arr.shots, 0.5); + let points_means = stats::means_i64(&data_arr.points, 0.5); + + Ok(Team { + team: params[2].parse::().unwrap_or(0), + trap_note: data_arr.trap_note.iter().sum::() as f64 / data_arr.trap_note.len() as f64, + climb: data_arr.climb.iter().sum::() as f64 / data_arr.climb.len() as f64, + buddy_climb: data_arr.buddy_climb.iter().sum::() as f64 / data_arr.buddy_climb.len() as f64, + intake: DataStats { + first: intake_qrt[0], + median: intake_qrt[1], + third: intake_qrt[2], + mean: intake_means[0], + decaying: intake_means[1] + }, + travel: DataStats { + first: travel_qrt[0], + median: travel_qrt[1], + third: travel_qrt[2], + mean: travel_means[0], + decaying: travel_means[1] + }, + outtake: DataStats { + first: outtake_qrt[0], + median: outtake_qrt[1], + third: outtake_qrt[2], + mean: outtake_means[0], + decaying: outtake_means[1] + }, + speaker: DataStats { + first: speaker_qrt[0], + median: speaker_qrt[1], + third: speaker_qrt[2], + mean: speaker_means[0], + decaying: speaker_means[1] + }, + amplifier: DataStats { + first: amplifier_qrt[0], + median: amplifier_qrt[1], + third: amplifier_qrt[2], + mean: amplifier_means[0], + decaying: amplifier_means[1] + }, + total: DataStats { + first: total_qrt[0], + median: total_qrt[1], + third: total_qrt[2], + mean: total_means[0], + decaying: total_means[1] + }, + points: DataStats { + first: points_qrt[0], + median: points_qrt[1], + third: points_qrt[2], + mean: points_means[0], + decaying: points_means[1] + }, + }) +} diff --git a/src/main.rs b/src/main.rs index 8fe0b72b..494e98cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,14 @@ -use std::{env, io, collections::HashMap, pin::Pin, sync::RwLock}; +use std::{env, io, collections::HashMap, fs, pin::Pin, sync::RwLock}; use actix_governor::{Governor, GovernorConfigBuilder}; use actix_http::StatusCode; use actix_identity::{CookieIdentityPolicy, Identity, IdentityService}; use actix_session::{SessionMiddleware, Session, config::PersistentSession}; -use actix_web::{error, middleware::{self, DefaultHeaders}, web, App, Error as AWError, HttpRequest, HttpResponse, HttpServer, cookie::Key, Responder, FromRequest, dev::Payload}; +use actix_web::{error, middleware::{self, DefaultHeaders}, web, App, Error as AWError, HttpRequest, HttpResponse, HttpServer, cookie::Key, Responder, FromRequest, dev::Payload, http::header::ContentType}; use actix_web_static_files::ResourceFiles; use dotenv::dotenv; use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod}; use r2d2_sqlite::{self, SqliteConnectionManager}; +use reqwest::Client; use serde::{Serialize, Deserialize}; use webauthn_rs::prelude::*; @@ -18,9 +19,11 @@ mod db_auth; mod db_main; mod db_transact; mod forward; +mod game_api; mod passkey; mod session; mod static_files; +mod stats; // hashmap containing user session IDs #[derive(Serialize, Deserialize, Default, Clone)] @@ -141,66 +144,99 @@ async fn data_get_meta() -> Result { ) } +fn access_denied_team() -> HttpResponse { + HttpResponse::Unauthorized() + .body("you must be affiliated with a valid team to access data") +} + // get detailed data by submission id. used in /detail -async fn data_get_detailed(path: web::Path, db: web::Data, _user: db_auth::User) -> Result { - Ok( - HttpResponse::Ok() - .insert_header(("Cache-Control", "no-cache")) - .json(db_main::execute(&db.main, db_main::MainData::GetDataDetailed, path).await?) - ) +async fn data_get_detailed(path: web::Path, db: web::Data, user: db_auth::User) -> Result { + if user.team != 0 { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json(db_main::execute(&db.main, db_main::MainData::GetDataDetailed, path).await?) + ) + } else { + Ok(access_denied_team()) + } } // check if a submission exists, by id. used in submit script to verify submission (verification is mostly a gimmick but whatever) -async fn data_get_exists(path: web::Path, db: web::Data, _user: db_auth::User) -> Result { - Ok( - HttpResponse::Ok() - .insert_header(("Cache-Control", "no-cache")) - .json(db_main::execute(&db.main, db_main::MainData::DataExists, path).await?) - ) +async fn data_get_exists(path: web::Path, db: web::Data, user: db_auth::User) -> Result { + if user.team != 0 { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json(db_main::execute(&db.main, db_main::MainData::DataExists, path).await?) + ) + } else { + Ok(access_denied_team()) + } } // get summary of all data for a given team at an event in a season. used on /browse -async fn data_get_main_brief_team(path: web::Path, db: web::Data, _user: db_auth::User) -> Result { - Ok( - HttpResponse::Ok() - .insert_header(("Cache-Control", "no-cache")) - .json(db_main::execute(&db.main, db_main::MainData::BriefTeam, path).await?) - ) +async fn data_get_main_brief_team(path: web::Path, db: web::Data, user: db_auth::User) -> Result { + if user.team != 0 { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json(db_main::execute(&db.main, db_main::MainData::BriefTeam, path).await?) + ) + } else { + Ok(access_denied_team()) + } } // get summary of all data for a given match at an event, in a specified season. used on /browsw -async fn data_get_main_brief_match(path: web::Path, db: web::Data, _user: db_auth::User) -> Result { - Ok( - HttpResponse::Ok() - .insert_header(("Cache-Control", "no-cache")) - .json(db_main::execute(&db.main, db_main::MainData::BriefMatch, path).await?) - ) +async fn data_get_main_brief_match(path: web::Path, db: web::Data, user: db_auth::User) -> Result { + if user.team != 0 { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json(db_main::execute(&db.main, db_main::MainData::BriefMatch, path).await?) + ) + } else { + Ok(access_denied_team()) + } } // get summary of all data from an event, given a season. used for /browse -async fn data_get_main_brief_event(path: web::Path, db: web::Data, _user: db_auth::User) -> Result { - Ok( - HttpResponse::Ok() - .insert_header(("Cache-Control", "no-cache")) - .json(db_main::execute(&db.main, db_main::MainData::BriefEvent, path).await?) - ) +async fn data_get_main_brief_event(path: web::Path, db: web::Data, user: db_auth::User) -> Result { + if user.team != 0 { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json(db_main::execute(&db.main, db_main::MainData::BriefEvent, path).await?) + ) + } else { + Ok(access_denied_team()) + } } -async fn data_get_main_brief_season(path: web::Path, db: web::Data, _user: db_auth::User) -> Result { - Ok( - HttpResponse::Ok() - .insert_header(("Cache-Control", "no-cache")) - .json(db_main::execute(&db.main, db_main::MainData::BriefSeason, path).await?) - ) +async fn data_get_main_brief_season(path: web::Path, db: web::Data, user: db_auth::User) -> Result { + if user.team != 0 { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json(db_main::execute(&db.main, db_main::MainData::BriefSeason, path).await?) + ) + } else { + Ok(access_denied_team()) + } } // get summary of all submissions created by a certain user id. used for /browse -async fn data_get_main_brief_user(path: web::Path, db: web::Data, _user: db_auth::User) -> Result { - Ok( - HttpResponse::Ok() - .insert_header(("Cache-Control", "no-cache")) - .json(db_main::execute(&db.main, db_main::MainData::BriefUser, path).await?) - ) +async fn data_get_main_brief_user(path: web::Path, db: web::Data, user: db_auth::User) -> Result { + if user.team != 0 { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json(db_main::execute(&db.main, db_main::MainData::BriefUser, path).await?) + ) + } else { + Ok(access_denied_team()) + } } // get basic data about all teams at an event, in a season. used for event rankings. ** NO AUTH ** @@ -214,21 +250,29 @@ async fn data_get_main_teams(path: web::Path, db: web::Data) // get POSTed data from form async fn data_post_submit(data: web::Json, db: web::Data, user: db_auth::User) -> Result { - Ok( - HttpResponse::Ok() - .insert_header(("Cache-Control", "no-cache")) - .json(db_main::execute_insert(&db.main, &db.transact, &db.auth, data, user).await?) - ) + if user.team != 0 { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json(db_main::execute_insert(&db.main, &db.transact, &db.auth, data, user).await?) + ) + } else { + Ok(access_denied_team()) + } } // forward frc api data for teams [deprecated] -async fn event_get_frc_api(req: HttpRequest, path: web::Path<(String, String)>, _user: db_auth::User) -> HttpResponse { +async fn event_get_frc_api(req: HttpRequest, path: web::Path<(String, String)>) -> HttpResponse { forward::forward_frc_api_event_teams(req, path).await } // forward frc api data for events. used on main form to ensure entered matches and teams are valid -async fn event_get_frc_api_matches(req: HttpRequest, path: web::Path<(String, String, String, String)>, _user: db_auth::User) -> HttpResponse { - forward::forward_frc_api_event_matches(req, path).await +async fn event_get_frc_api_matches(req: HttpRequest, path: web::Path<(String, String)>/*, user: db_auth::User*/) -> HttpResponse { + // if user.team != 0 { + forward::forward_frc_api_event_matches(req, path).await + // } else { + // access_denied_team() + // } } // get all valid submission IDs. used on /manage to create list of IDs that can be acted on @@ -422,6 +466,16 @@ async fn misc_get_whoami(user: db_auth::User) -> Result { ) } +// if you aren't D6MFYYVHA8 you may want to change this +const APPLE_APP_SITE_ASSOC: &str = "{\"webcredentials\":{\"apps\":[\"D6MFYYVHA8.com.jayagra.beartracks\",\"D6MFYYVHA8.com.jayagra.beartracks-scout\",\"D6MFYYVHA8.com.jayagra.beartracks-manage\"]}}"; +async fn misc_apple_app_site_association() -> Result { + Ok( + HttpResponse::Ok() + .content_type(ContentType::json()) + .body(APPLE_APP_SITE_ASSOC) + ) +} + // get all points. used to construct the leaderboard async fn points_get_all(db: web::Data, _user: db_auth::User) -> Result { Ok( @@ -449,6 +503,51 @@ async fn debug_get_user(user: db_auth::User) -> Result { ) } +async fn game_get_cards(db: web::Data, user: db_auth::User) -> Result { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json( + game_api::get_owned_cards(&db.auth, user).await? + ) + ) +} + +async fn game_open_lootbox(db: web::Data, user: db_auth::User) -> Result { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json( + game_api::open_loot_box(&db.auth, &db.main, user).await? + ) + ) +} + +async fn game_set_hand(db: web::Data, data: web::Json, user: db_auth::User) -> Result { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json( + game_api::set_held_cards(&db.auth, user, &data).await? + ) + ) +} + +async fn game_get_team(req: HttpRequest, db: web::Data, _user: db_auth::User) -> Result { + Ok( + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-cache")) + .json( + game_api::execute( + &db.main, + req.match_info().get("season").unwrap().parse().unwrap(), + req.match_info().get("event").unwrap().parse().unwrap(), + req.match_info().get("team").unwrap().parse().unwrap(), + ).await? + ) + ) +} + include!(concat!(env!("OUT_DIR"), "/generated.rs")); #[actix_web::main] @@ -464,6 +563,59 @@ async fn main() -> io::Result<()> { println!("[OK] starting in release mode"); } + // cache all possible files + let seasons = env::var("SEASONS").unwrap_or_else(|_| "0".to_string()).split(",").map(|s| s.to_string()).collect::>(); + let events = env::var("EVENTS").unwrap_or_else(|_| "0".to_string()).split(",").map(|s| s.to_string()).collect::>(); + + for i in 0..seasons.len() { + for j in 0..events.len() { + // cache team list + let team_target_url = format!("https://frc-api.firstinspires.org/v3.0/{}/teams?eventCode={}", seasons[i], events[j]); + let team_client = Client::new(); + let team_response = team_client + .request(actix_http::Method::GET, team_target_url) + .header("Authorization", format!("Basic {}", env::var("FRC_API_KEY").unwrap_or_else(|_| "NONE".to_string()))) + .send() + .await; + + match team_response { + Ok(response) => { + if response.status() == 200 { + fs::create_dir_all(format!("cache/frc_api/{}/{}", seasons[i], events[j]))?; + fs::write(format!("cache/frc_api/{}/{}/teams.json", seasons[i], events[j]), response.text().await.unwrap()).expect(format!("Failed to cache {}/{} team JSON. Could not write file.", seasons[i], events[j]).as_str()); + } else { + log::error!("Failed to cache {}/{} team JSON. Response status {}.", seasons[i], events[j], response.status()); + } + } + Err(_) => { + log::error!("Failed to cache {}/{} team JSON. Response was not OK.", seasons[i], events[j]); + }, + } + + let match_target_url = format!("https://frc-api.firstinspires.org/v3.0/{}/schedule/{}?tournamentLevel=qualification", seasons[i], events[j]); + let match_client = Client::new(); + let match_response = match_client + .request(actix_http::Method::GET, match_target_url) + .header("Authorization", format!("Basic {}", env::var("FRC_API_KEY").unwrap_or_else(|_| "NONE".to_string()))) + .send() + .await; + + match match_response { + Ok(response) => { + if response.status() == 200 { + fs::create_dir_all(format!("cache/frc_api/{}/{}", seasons[i], events[j]))?; + fs::write(format!("cache/frc_api/{}/{}/matches.json", seasons[i], events[j]), response.text().await.unwrap()).expect(format!("Failed to cache {}/{} match JSON. Could not write file.", seasons[i], events[j]).as_str()); + } else { + log::error!("Failed to cache {}/{} match JSON. Response status {}.", seasons[i], events[j], response.status()); + } + } + Err(_) => { + log::error!("Failed to cache {}/{} match JSON. Response was not OK.", seasons[i], events[j]); + }, + } + } + } + // hashmap w: web::Data>ith user sessions in it let sessions: web::Data> = web::Data::new(RwLock::new(Sessions { user_map: HashMap::new(), @@ -547,13 +699,15 @@ async fn main() -> io::Result<()> { .build() ) // default headers for caching. overridden on most all api endpoints - .wrap(DefaultHeaders::new().add(("Cache-Control", "public, max-age=23328000")).add(("X-bearTracks", "4.0.0"))) + .wrap(DefaultHeaders::new().add(("Cache-Control", "public, max-age=23328000")).add(("X-bearTracks", "5.0.2"))) /* src endpoints */ // GET individual files .route("/", web::get().to(static_files::static_index)) .route("/blackjack", web::get().to(static_files::static_blackjack)) .route("/create", web::get().to(static_files::static_create)) + .route("/main", web::get().to(static_files::static_main)) .route("/login", web::get().to(static_files::static_login)) + .route("/passkey", web::get().to(static_files::static_passkey)) .route("/pointRecords", web::get().to(static_files::static_point_records)) .route("/points", web::get().to(static_files::static_points)) .route("/scouts", web::get().to(static_files::static_scouts)) @@ -617,13 +771,21 @@ async fn main() -> io::Result<()> { // GET .service(web::resource("/api/v1/transact/me").route(web::get().to(misc_get_transact_me))) .service(web::resource("/api/v1/whoami").route(web::get().to(misc_get_whoami))) + .service(web::resource("/apple-app-site-association").route(web::get().to(misc_apple_app_site_association))) /* debug endpoints */ // GET .service(web::resource("/api/v1/debug/user").route(web::get().to(debug_get_user))) + /* robot game endpoints */ + // GET + .service(web::resource("/api/v1/game/all_owned_cards").route(web::get().to(game_get_cards))) + .service(web::resource("/api/v1/game/team_data/{season}/{event}/{team}").route(web::get().to(game_get_team))) + .service(web::resource("/api/v1/game/open_lootbox").route(web::get().to(game_open_lootbox))) + // POST + .service(web::resource("/api/v1/game/set_hand").route(web::post().to(game_set_hand))) }) .bind_openssl(format!("{}:443", env::var("HOSTNAME").unwrap_or_else(|_| "localhost".to_string())), builder)? .bind((env::var("HOSTNAME").unwrap_or_else(|_| "localhost".to_string()), 80))? - .workers(2) + .workers(4) .run() .await } \ No newline at end of file diff --git a/src/static_files.rs b/src/static_files.rs index 4f06c9ca..6399be9d 100644 --- a/src/static_files.rs +++ b/src/static_files.rs @@ -5,8 +5,10 @@ use actix_web::{HttpRequest, HttpResponse, http::header::{ContentType, CacheCont const INDEX_HTML: &str = include_str!("../static/index.html"); const BLACKJACK_HTML: &str = include_str!("../static/blackjack.html"); const CREATE_HTML: &str = include_str!("../static/create.html"); +const MAIN_HTML: &str = include_str!("../static/main.html"); const LOGIN_HTML: &str = include_str!("../static/login.html"); const POINT_RECORDS_HTML: &str = include_str!("../static/pointRecords.html"); +const PASSKEY_HTML: &str = include_str!("../static/passkey.html"); const POINTS_HTML: &str = include_str!("../static/points.html"); const SCOUTS_HTML: &str = include_str!("../static/scouts.html"); const SETTINGS_HTML: &str = include_str!("../static/settings.html"); @@ -46,12 +48,24 @@ pub async fn static_create() -> HttpResponse { .body(CREATE_HTML) } +pub async fn static_main() -> HttpResponse { + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(MAIN_HTML) +} + pub async fn static_login() -> HttpResponse { HttpResponse::Ok() .content_type(ContentType::html()) .body(LOGIN_HTML) } +pub async fn static_passkey() -> HttpResponse { + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(PASSKEY_HTML) +} + pub async fn static_point_records() -> HttpResponse { HttpResponse::Ok() .content_type(ContentType::html()) diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 00000000..6f07b69c --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,38 @@ +pub fn quartiles_i64(data: &Vec) -> Vec { + if data.len() > 0 { + let mut sorted_data = data.clone(); + sorted_data.sort(); + let data_length: f64 = sorted_data.len() as f64; + let mut quartiles: Vec = Vec::new(); + // first quartile + if (data_length * 0.25) % 1.0 == 0.0 { + quartiles.push((sorted_data[((data_length * 0.25) - 1.0) as usize] + sorted_data[(data_length * 0.25) as usize]) / 2 as i64); + } else { + quartiles.push(sorted_data[(data_length * 0.25).floor() as usize]); + } + // second quartile (median) + if (data_length * 0.5) % 1.0 == 0.0 { + quartiles.push((sorted_data[((data_length * 0.5) - 1.0) as usize] + sorted_data[(data_length * 0.5) as usize]) / 2 as i64); + } else { + quartiles.push(sorted_data[(data_length * 0.5).floor() as usize]); + } + // third quartile + if (data_length * 0.75) % 1.0 == 0.0 { + quartiles.push((sorted_data[((data_length * 0.75) - 1.0) as usize] + sorted_data[(data_length * 0.75) as usize]) / 2 as i64); + } else { + quartiles.push(sorted_data[(data_length * 0.75).floor() as usize]); + } + return quartiles + } + return vec!(0, 0, 0) +} + +pub fn means_i64(data: &Vec, first_wt: f64) -> Vec { + if data.len() > 0 { + let mut means: Vec = Vec::new(); + means.push(data.iter().sum::() / data.len() as i64); + means.push(((data[0] as f64 * first_wt) + (data.iter().sum::() as f64 * (1.0 - first_wt))) as i64); + return means + } + return vec!(0, 0); +} \ No newline at end of file diff --git a/static/blackjack.html b/static/blackjack.html index 23fec1f2..6b11d208 100644 --- a/static/blackjack.html +++ b/static/blackjack.html @@ -5,12 +5,12 @@ Blackjack - bearTracks - + - +
diff --git a/static/create.html b/static/create.html index 5b770cd5..72e58f57 100644 --- a/static/create.html +++ b/static/create.html @@ -5,7 +5,7 @@ Create Account - bearTracks - + @@ -17,9 +17,9 @@

bearTracks
CRESCENDO

Create Account

-


+
00000 for no team





-


+






diff --git a/static/favicon.ico b/static/favicon.ico index 86c47f3c..bd41d270 100644 Binary files a/static/favicon.ico and b/static/favicon.ico differ diff --git a/static/index.html b/static/index.html index 41196968..1cde7545 100644 --- a/static/index.html +++ b/static/index.html @@ -2,30 +2,31 @@ - CRESCENDO Scouting - bearTracks + bearTracks - + + - +
-

bearTracks
CRESCENDO


+

bearTracks
CRESCENDO

- \ No newline at end of file diff --git a/static/login.html b/static/login.html index 86c12d0e..ee3ccaf4 100644 --- a/static/login.html +++ b/static/login.html @@ -1 +1 @@ -

loading web assembly...

\ No newline at end of file +Log In - bearTracks

loading web assembly...

\ No newline at end of file diff --git a/static/main.html b/static/main.html new file mode 100644 index 00000000..ef084c6c --- /dev/null +++ b/static/main.html @@ -0,0 +1,105 @@ + + + + + Scout - bearTracks + + + + + + + + + + +
+
+

bearTracks
CRESCENDO

+
+ + +
+
+ + +
+
+ + +

+ +
+ + + +
+ + + + \ No newline at end of file diff --git a/static/passkey.html b/static/passkey.html new file mode 100644 index 00000000..5903c312 --- /dev/null +++ b/static/passkey.html @@ -0,0 +1 @@ +Register Passkey - bearTracks

loading web assembly...

\ No newline at end of file diff --git a/static/pointRecords.html b/static/pointRecords.html index 3b5d7ff1..864fb9c6 100644 --- a/static/pointRecords.html +++ b/static/pointRecords.html @@ -5,7 +5,7 @@ Point Records - bearTracks - + diff --git a/static/points.html b/static/points.html index 8664955d..e9c8fcd5 100644 --- a/static/points.html +++ b/static/points.html @@ -5,7 +5,7 @@ Points - bearTracks - + diff --git a/static/public/css/float.css b/static/public/css/float.css index 993eefbb..1d57ac9a 100644 --- a/static/public/css/float.css +++ b/static/public/css/float.css @@ -1,14 +1,14 @@ body { --fieldsetColor: rgba(0,0,0,0); - --bodyColor: #121212; - --textColor: #fff; - --transparency: rgba(0,0,0,0); --defaultInputColor: #fff; - --inputColorSelected: #ffb600; - --gameFlairColor: #68c3e2; - --cancelColor: #e90202; - --returnColor: #ffb600; - --checkAccent: #68c3e2; + --bodyColor: #282828; + --textColor: #ebdbb2; + --defaultInputColor: #504945; + --inputColorSelected: #b8bb26; + --gameFlairColor: #83a598; + --cancelColor: #cc241d; + --returnColor: #d3869b; + --checkAccent: #fe8019; display: flex; justify-content: center; align-items: center; @@ -87,46 +87,22 @@ h1 { max-width: 100%; } select { - background-color: var(--transparency); + background-color: transparent; border: none; color: var(--textColor); border-bottom: 2px solid var(--defaultInputColor); font-size: 16px; margin-top: 2vh; } -input[type='text'], input[type='email'], input[type='password'] { +input[type='text'], input[type='email'], input[type='password'], input[type='number'], input[type='tel'] { border: none; - background-color: var(--transparency); + background-color: transparent; color: var(--textColor); border-bottom: 1px solid var(--defaultInputColor); width: 80%; } -input[type='number'] { - border: none; - background-color: var(--transparency); - color: var(--textColor); - border-bottom: 1px solid var(--defaultInputColor); - width: 80%; -} -input[type='tel'] { - border: none; - background-color: var(--transparency); - color: var(--textColor); - border-bottom: 1px solid var(--defaultInputColor); - width: 80%; -} -input[type='number']:focus { - background-color: var(--transparency); - outline: none; - border-bottom: 2px solid var(--inputColorSelected); -} -input[type='tel']:focus { - background-color: var(--transparency); - outline: none; - border-bottom: 2px solid var(--inputColorSelected); -} -input[type='text']:focus, input[type='email']:focus, input[type='password']:focus { - background-color: var(--transparency); +input[type='text']:focus, input[type='email']:focus, input[type='password']:focus, input[type='tel']:focus, input[type='number']:focus { + background-color: transparent; outline: none; border-bottom: 2px solid var(--inputColorSelected); } @@ -169,16 +145,17 @@ textarea { font-size: 16px; } textarea { - border: none; - border-bottom: 1px solid var(--defaultInputColor); - background-color: var(--transparency); + border: 2px solid var(--defaultInputColor); + border-radius: 11px; + background-color: transparent; color: var(--textColor); + padding: 0.5rem; resize: none; width: 80%; } textarea:focus { outline: none; - border-bottom: 1px solid var(--inputColorSelected); + border-color: var(--inputColorSelected); } code { font-family:'SF Mono', SFMono-Regular, ui-monospace, Menlo, courier new, monospace; diff --git a/static/public/css/float.min.css b/static/public/css/float.min.css index fc5696ae..9a302040 100644 --- a/static/public/css/float.min.css +++ b/static/public/css/float.min.css @@ -1 +1 @@ -body{--fieldsetColor:rgba(0, 0, 0, 0);--bodyColor:#121212;--textColor:#fff;--transparency:rgba(0, 0, 0, 0);--defaultInputColor:#fff;--inputColorSelected:#ffb600;--gameFlairColor:#68c3e2;--cancelColor:#e90202;--returnColor:#ffb600;--checkAccent:#68c3e2;display:flex;justify-content:center;align-items:center;}@media(prefers-color-scheme: light){body{--bodyColor:#fff;--textColor:#000;--defaultInputColor:#000;--inputColorSelected:#ffb600;--gameFlairColor:#68c3e2;--checkAccent:#68c3e2;}}body.dark-mode{--bodyColor:#121212;--textColor:#fff;--defaultInputColor:#fff;--inputColorSelected:#ffb600;--gameFlairColor:#68c3e2;--checkAccent:#68c3e2;}body.light-mode{--bodyColor:#fff;--textColor:#000;--defaultInputColor:#000;--inputColorSelected:#ffb600;--gameFlairColor:#68c3e2;--checkAccent:#68c3e2;}body.gruvbox{--bodyColor:#282828;--textColor:#ebdbb2;--defaultInputColor:#504945;--inputColorSelected:#b8bb26;--gameFlairColor:#83a598;--cancelColor:#cc241d;--returnColor:#d3869b;--checkAccent:#fe8019;}@font-face{font-family:'raleway-300';src:url('fonts/Raleway-300.ttf') format('truetype');font-display:swap;}@font-face{font-family:'raleway-500';src:url('fonts/Raleway-500.ttf') format('truetype');font-display:swap;}body,html{font-family:'raleway-300',-apple-system,BlinkMacSystemFont,sans-serif;}body{background-color:var(--bodyColor);color:var(--textColor);width:100vw;margin:0;}fieldset{border:0.15rem solid var(--fieldsetColor);border-radius:0.5rem;margin-top:0.25rem;margin-bottom:0.25rem;background-color:var(--fieldsetColor);}h1{text-align:center;font-family:'raleway-500',-apple-system,BlinkMacSystemFont,sans-serif;}.container{justify-content:center;display:flex;width:100vw;max-width:100%;}select{background-color:var(--transparency);border:none;color:var(--textColor);border-bottom:2px solid var(--defaultInputColor);font-size:16px;margin-top:2vh;}input[type='text'],input[type='email'],input[type='password']{border:none;background-color:var(--transparency);color:var(--textColor);border-bottom:1px solid var(--defaultInputColor);width:80%;}input[type='number']{border:none;background-color:var(--transparency);color:var(--textColor);border-bottom:1px solid var(--defaultInputColor);width:80%;}input[type='tel']{border:none;background-color:var(--transparency);color:var(--textColor);border-bottom:1px solid var(--defaultInputColor);width:80%;}input[type='number']:focus{background-color:var(--transparency);outline:none;border-bottom:2px solid var(--inputColorSelected);}input[type='tel']:focus{background-color:var(--transparency);outline:none;border-bottom:2px solid var(--inputColorSelected);}input[type='text']:focus,input[type='email']:focus,input[type='password']:focus{background-color:var(--transparency);outline:none;border-bottom:2px solid var(--inputColorSelected);}select:focus{outline:none;border-bottom:2px solid var(--inputColorSelected);}span{color:var(--textColor);}label{color:var(--textColor);}.gametitle{color:var(--inputColorSelected);font-family:'raleway-500',-apple-system,BlinkMacSystemFont,sans-serif;}.gameflair1{color:var(--gameFlairColor);}.checkbig{-webkit-transform:scale(2);-moz-transform:scale(2);-ms-transform:scale(2);transform:scale(2);accent-color:var(---checkAccent) !important;}.smallerInput{width:40% !important;}.lowerSelect{position:relative;top:5px;}input,select,textarea{-webkit-border-radius:0;border-radius:0;font-size:16px;}textarea{border:none;border-bottom:1px solid var(--defaultInputColor);background-color:var(--transparency);color:var(--textColor);resize:none;width:80%;}textarea:focus{outline:none;border-bottom:1px solid var(--inputColorSelected);}code{font-family:'SF Mono',SFMono-Regular,ui-monospace,Menlo,courier new,monospace;}label{font-weight:700;}.uiButton{border-radius:0.5rem;border-style:solid;border-width:0.875rem;margin-top:1rem;}.cancelButton{color:#fff;background-color:var(--cancelColor);border-color:var(--cancelColor);}.returnButton{color:#000;background-color:var(--returnColor);border-color:var(--returnColor);}.actionButton{color:#000;background-color:var(--gameFlairColor);border-color:var(--gameFlairColor);}.actionButton:disabled{background-color:color-mix(in srgb, var(--gameFlairColor) 15%, var(--bodyColor));border-color:color-mix(in srgb, var(--gameFlairColor) 15%, var(--bodyColor));}form{width:95%;}.dummy{display:flex;flex-direction:column;align-items:center;}.w3{font-weight:300;}.bigLink{margin-bottom:5%;}.actLink{all:unset;color:var(--gameFlairColor);}.credInput{min-width:16rem;}.centerText{text-align:center;}.vaMiddle{vertical-align:middle;} \ No newline at end of file +body{--fieldsetColor:rgba(0,0,0,0);--defaultInputColor:#fff;--bodyColor:#282828;--textColor:#ebdbb2;--defaultInputColor:#504945;--inputColorSelected:#b8bb26;--gameFlairColor:#83a598;--cancelColor:#cc241d;--returnColor:#d3869b;--checkAccent:#fe8019;display:flex;justify-content:center;align-items:center}@media (prefers-color-scheme: light){body{--bodyColor:#fff;--textColor:#000;--defaultInputColor:#000;--inputColorSelected:#ffb600;--gameFlairColor:#68c3e2;--checkAccent:#68c3e2}}body.dark-mode{--bodyColor:#121212;--textColor:#fff;--defaultInputColor:#fff;--inputColorSelected:#ffb600;--gameFlairColor:#68c3e2;--checkAccent:#68c3e2}body.light-mode{--bodyColor:#fff;--textColor:#000;--defaultInputColor:#000;--inputColorSelected:#ffb600;--gameFlairColor:#68c3e2;--checkAccent:#68c3e2}body.gruvbox{--bodyColor:#282828;--textColor:#ebdbb2;--defaultInputColor:#504945;--inputColorSelected:#b8bb26;--gameFlairColor:#83a598;--cancelColor:#cc241d;--returnColor:#d3869b;--checkAccent:#fe8019}@font-face{font-family:'raleway-300';src:url('fonts/Raleway-300.ttf') format('truetype');font-display:swap}@font-face{font-family:'raleway-500';src:url('fonts/Raleway-500.ttf') format('truetype');font-display:swap}body,html{font-family:'raleway-300', -apple-system, BlinkMacSystemFont, sans-serif}body{background-color:var(--bodyColor);color:var(--textColor);width:100vw;margin:0}fieldset{border:0.15rem solid var(--fieldsetColor);border-radius:0.5rem;margin-top:0.25rem;margin-bottom:0.25rem;background-color:var(--fieldsetColor)}h1{text-align:center;font-family:'raleway-500', -apple-system, BlinkMacSystemFont, sans-serif}.container{justify-content:center;display:flex;width:100vw;max-width:100%}select{background-color:transparent;border:none;color:var(--textColor);border-bottom:2px solid var(--defaultInputColor);font-size:16px;margin-top:2vh}input[type='text'],input[type='email'],input[type='password'],input[type='number'],input[type='tel']{border:none;background-color:transparent;color:var(--textColor);border-bottom:1px solid var(--defaultInputColor);width:80%}input[type='text']:focus,input[type='email']:focus,input[type='password']:focus,input[type='tel']:focus,input[type='number']:focus{background-color:transparent;outline:none;border-bottom:2px solid var(--inputColorSelected)}select:focus{outline:none;border-bottom:2px solid var(--inputColorSelected)}span{color:var(--textColor)}label{color:var(--textColor)}.gametitle{color:var(--inputColorSelected);font-family:'raleway-500', -apple-system, BlinkMacSystemFont, sans-serif}.gameflair1{color:var(--gameFlairColor)}.checkbig{-webkit-transform:scale(2);-moz-transform:scale(2);-ms-transform:scale(2);transform:scale(2);accent-color:var(---checkAccent) !important}.smallerInput{width:40% !important}.lowerSelect{position:relative;top:5px}input,select,textarea{-webkit-border-radius:0;border-radius:0;font-size:16px}textarea{border:2px solid var(--defaultInputColor);border-radius:11px;background-color:transparent;color:var(--textColor);padding:0.5rem;resize:none;width:80%}textarea:focus{outline:none;border-color:var(--inputColorSelected)}code{font-family:'SF Mono', SFMono-Regular, ui-monospace, Menlo, courier new, monospace}label{font-weight:700}.uiButton{border-radius:0.5rem;border-style:solid;border-width:0.875rem;margin-top:1rem}.cancelButton{color:#fff;background-color:var(--cancelColor);border-color:var(--cancelColor)}.returnButton{color:#000;background-color:var(--returnColor);border-color:var(--returnColor)}.actionButton{color:#000;background-color:var(--gameFlairColor);border-color:var(--gameFlairColor)}.actionButton:disabled{background-color:color-mix(in srgb, var(--gameFlairColor) 15%, var(--bodyColor));border-color:color-mix(in srgb, var(--gameFlairColor) 15%, var(--bodyColor))}form{width:95%}.dummy{display:flex;flex-direction:column;align-items:center}.w3{font-weight:300}.bigLink{margin-bottom:5%}.actLink{all:unset;color:var(--gameFlairColor)}.credInput{min-width:16rem}.centerText{text-align:center}.vaMiddle{vertical-align:middle} \ No newline at end of file diff --git a/static/public/css/main_form.css b/static/public/css/main_form.css new file mode 100644 index 00000000..0ae87815 --- /dev/null +++ b/static/public/css/main_form.css @@ -0,0 +1,71 @@ +.VStack { + display: flex; + flex-direction: column; + justify-content: space-around; +} +.HStack { + display: flex; + flex-direction: row; + justify-content: space-around; +} +.continue_button, .time_button, .cycle_button { + background-color: rgba(0, 0, 0, 0.3); + border: none; + border-radius: 0.25rem; + padding: 0 0.75rem 0 0.75rem; +} +.continue_button:active, .time_button:active, .cycle_button:active { + background-color: rgba(0, 0, 0, 0.45); +} +.continue_button, .time_button { + color: #0a84ff; + min-width: 20vw; + max-width: 25vw; +} +.continue_button { + align-self: center; + min-width: 15vw; + max-width: 70vw; +} +.cycle_button { + align-self: center; + color: #32d74b; + min-width: 50vw; +} +.counter { + color: #fff; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} +.ios-switch i { + position: relative; + display: inline-block; + margin-right: .5rem; + width: 46px; + height: 26px; + background-color: #000; + border-radius: 23px; + vertical-align: text-bottom; +} +.ios-switch i::before { + content: ""; + position: absolute; + left: 0; + width: 42px; + height: 22px; + background-color: #000; + border-radius: 11px; +} +.ios-switch i::after { + content: ""; + position: absolute; + left: 0; + width: 22px; + height: 22px; + background-color: #fff; + border-radius: 11px; + transform: translate3d(2px, 2px, 0); +} +.ios-switch input { display: none; } +.ios-switch input:checked + i { background-color: #32d74b; } +.ios-switch input:checked + i::before { transform: translate3d(18px, 2px, 0) scale3d(0, 0, 0); } +.ios-switch input:checked + i::after { transform: translate3d(22px, 2px, 0); } \ No newline at end of file diff --git a/static/public/css/main_form.min.css b/static/public/css/main_form.min.css new file mode 100644 index 00000000..c224d66a --- /dev/null +++ b/static/public/css/main_form.min.css @@ -0,0 +1 @@ +.VStack{display:flex;flex-direction:column;justify-content:space-around}.HStack{display:flex;flex-direction:row;justify-content:space-around}.continue_button,.cycle_button,.time_button{background-color:rgba(0, 0, 0, 0.3);border:none;border-radius:0.25rem;padding:0 0.75rem}.continue_button:active,.cycle_button:active,.time_button:active{background-color:rgba(0, 0, 0, 0.45)}.continue_button,.time_button{color:#0a84ff;min-width:20vw;max-width:25vw}.continue_button{align-self:center;min-width:15vw;max-width:70vw}.cycle_button{align-self:center;color:#32d74b;min-width:50vw}.counter{color:#fff;font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif}.ios-switch i{position:relative;display:inline-block;margin-right:0.5rem;width:46px;height:26px;background-color:#000;border-radius:23px;vertical-align:text-bottom}.ios-switch i::before{content:"";position:absolute;left:0;width:42px;height:22px;background-color:#000;border-radius:11px}.ios-switch i::after{content:"";position:absolute;left:0;width:22px;height:22px;background-color:#fff;border-radius:11px;transform:translate3d(2px, 2px, 0)}.ios-switch input{display:none}.ios-switch input:checked + i{background-color:#32d74b}.ios-switch input:checked + i::before{transform:translate3d(18px, 2px, 0) scale3d(0, 0, 0)}.ios-switch input:checked + i::after{transform:translate3d(22px, 2px, 0)} \ No newline at end of file diff --git a/static/public/js/form/form.js b/static/public/js/form/form.js index fced84fc..f7cce572 100644 --- a/static/public/js/form/form.js +++ b/static/public/js/form/form.js @@ -10,16 +10,16 @@ function themeHandle() { document.body.classList.replace("dark-mode", "light-mode"); document.getElementById("themeMeta").content = "#ffffff"; break; - case "gruvbox": - document.body.classList.replace("dark-mode", "gruvbox"); - document.getElementById("themeMeta").content = "#282828"; - break; case "dark": - case undefined: - default: document.body.classList.replace("light-mode", "dark-mode"); document.getElementById("themeMeta").content = "#121212"; break; + case "gruvbox": + case undefined: + default: + document.body.classList.replace("dark-mode", "gruvbox"); + document.getElementById("themeMeta").content = "#282828"; + break; } } const waitMs = (ms) => new Promise((res) => setTimeout(res, ms)); diff --git a/static/public/js/form/form.min.js b/static/public/js/form/form.min.js index f167e3e3..7feb73b5 100644 --- a/static/public/js/form/form.min.js +++ b/static/public/js/form/form.min.js @@ -1 +1 @@ -"use strict";window.getThemeCookie=()=>{var e=RegExp("4c454a5b1bedf6a1=[^;]+").exec(document.cookie);return decodeURIComponent(e?e.toString().replace(/^[^=]+./,""):"")},window.themeHandle=()=>{let e=getThemeCookie();switch(e){case"light":document.body.classList.replace("dark-mode","light-mode"),document.getElementById("themeMeta").content="#ffffff";break;case"gruvbox":document.body.classList.replace("dark-mode","gruvbox"),document.getElementById("themeMeta").content="#282828";break;default:document.body.classList.replace("light-mode","dark-mode"),document.getElementById("themeMeta").content="#121212"}},window.waitMs=e=>new Promise(t=>setTimeout(t,e)); \ No newline at end of file +"use strict";function getThemeCookie(){var e=RegExp("4c454a5b1bedf6a1=[^;]+").exec(document.cookie);return decodeURIComponent(e?e.toString().replace(/^[^=]+./,""):"")}function themeHandle(){let e=getThemeCookie();switch(e){case"light":document.body.classList.replace("dark-mode","light-mode"),document.getElementById("themeMeta").content="#ffffff";break;case"dark":document.body.classList.replace("light-mode","dark-mode"),document.getElementById("themeMeta").content="#121212";break;default:document.body.classList.replace("dark-mode","gruvbox"),document.getElementById("themeMeta").content="#282828"}}const waitMs=e=>new Promise(t=>setTimeout(t,e)); \ No newline at end of file diff --git a/static/public/js/form/form.ts b/static/public/js/form/form.ts index ac61848f..f9381a3f 100644 --- a/static/public/js/form/form.ts +++ b/static/public/js/form/form.ts @@ -12,14 +12,14 @@ function themeHandle() { document.body.classList.replace("dark-mode", "light-mode"); (document.getElementById("themeMeta") as HTMLMetaElement).content = "#ffffff"; break; - case "gruvbox": - document.body.classList.replace("dark-mode", "gruvbox"); - (document.getElementById("themeMeta") as HTMLMetaElement).content = "#282828"; - break; - case "dark": case undefined: default: + case "dark": document.body.classList.replace("light-mode", "dark-mode"); (document.getElementById("themeMeta") as HTMLMetaElement).content = "#121212"; break; + case "gruvbox": case undefined: default: + document.body.classList.replace("dark-mode", "gruvbox"); + (document.getElementById("themeMeta") as HTMLMetaElement).content = "#282828"; + break; } } diff --git a/static/public/js/homepage/homepage.js b/static/public/js/homepage/homepage.js deleted file mode 100644 index 65771da3..00000000 --- a/static/public/js/homepage/homepage.js +++ /dev/null @@ -1,12 +0,0 @@ -const getCookie = (name) => { - var cookieString = RegExp(name + "=[^;]+").exec(document.cookie); - return decodeURIComponent(!!cookieString ? cookieString.toString().replace(/^[^=]+./, "") : ""); -}; -if (getCookie("lead") === "true") { - var url = document.getElementById("additionalUrl"); - url.style.display = "unset"; -} -else if (Number(getCookie("childTeamLead")) !== 0) { - var url = document.getElementById("additionalUrl2"); - url.style.display = "unset"; -} diff --git a/static/public/js/homepage/homepage.min.js b/static/public/js/homepage/homepage.min.js deleted file mode 100644 index 4f86d384..00000000 --- a/static/public/js/homepage/homepage.min.js +++ /dev/null @@ -1 +0,0 @@ -const getCookie=e=>{var t=RegExp(e+"=[^;]+").exec(document.cookie);return decodeURIComponent(t?t.toString().replace(/^[^=]+./,""):"")};if("true"===getCookie("lead")){var e=document.getElementById("additionalUrl");e.style.display="unset"}else if(0!==Number(getCookie("childTeamLead"))){var e=document.getElementById("additionalUrl2");e.style.display="unset"} \ No newline at end of file diff --git a/static/public/js/homepage/homepage.ts b/static/public/js/homepage/homepage.ts deleted file mode 100644 index 280b4e09..00000000 --- a/static/public/js/homepage/homepage.ts +++ /dev/null @@ -1,12 +0,0 @@ -const getCookie = (name: string): string => { - var cookieString = RegExp(name + "=[^;]+").exec(document.cookie); - return decodeURIComponent(!!cookieString ? cookieString.toString().replace(/^[^=]+./, "") : ""); -} - -if (getCookie("lead") === "true") { - var url: HTMLElement = (document.getElementById("additionalUrl") as HTMLElement); - url.style.display = "unset"; -} else if (Number(getCookie("childTeamLead")) !== 0) { - var url: HTMLElement = (document.getElementById("additionalUrl2") as HTMLElement); - url.style.display = "unset"; -} \ No newline at end of file diff --git a/static/public/js/main/main.js b/static/public/js/main/main.js new file mode 100644 index 00000000..6a9ce170 --- /dev/null +++ b/static/public/js/main/main.js @@ -0,0 +1,77 @@ +import { _get } from "../_modules/get/get.min.js"; +// get events +const API_META = "/api/v1/data"; +const API_MATCHES = ["/api/v1/events/matches/", /* season */ "/", /* event */ "/qual/true"]; +const API_WHOAMI = "/api/v1/whoami"; +var match_schedule; +function init() { + load_events(); + try { + load_matches(document.getElementById("event_code").value); + match_num_entry(document.getElementById("match_num_input").value); + } + catch { } + document.getElementById("event_code").addEventListener("change", () => { + load_matches(document.getElementById("event_code").value); + }); + document.getElementById("match_num_input").addEventListener("change", () => { + match_num_entry(document.getElementById("match_num_input").value); + }); + let adv_buttons = document.getElementsByClassName("continue_button"); + for (var i = 0; i < 3; i++) { + adv_buttons[i].addEventListener("click", () => { + advance_screen(i + 1); + }); + } +} +function load_events() { + _get(API_META, null).then((result) => { + result.events.forEach(event_code => { + document.getElementById("event_code").insertAdjacentHTML("beforeend", ``); + }); + }).catch((error) => { + alert(`failed to load valid event codes. ${error}`); + }); +} +function load_matches(event) { + _get(API_MATCHES[0] + "2023" + API_MATCHES[1] + event + API_MATCHES[2], null).then((result) => { + if (result.Schedule.length != 0) { + match_schedule = result.Schedule; + } + else { + alert("match schedule is not yet posted"); + match_schedule = []; + } + }).catch((error) => { + alert(`failed to load matches. ${error}`); + match_schedule = []; + }); +} +function set_option(element, value) { + element.innerText = value; + element.value = value; +} +function match_num_entry(entry) { + let entry_num = Number(entry); + if (entry_num > match_schedule.length || entry_num < 1) { + document.getElementById("bad_match_num").innerText = `invalid. must be between 1 and ${match_schedule.length}`; + document.getElementById("bad_match_num").style.display = "unset"; + } + else { + document.getElementById("bad_match_num").style.display = "none"; + let select_elements = document.getElementsByClassName("teamNumOption"); + set_option(select_elements[3], match_schedule[entry_num - 1].teams[0].teamNumber); + set_option(select_elements[4], match_schedule[entry_num - 1].teams[1].teamNumber); + set_option(select_elements[5], match_schedule[entry_num - 1].teams[2].teamNumber); + set_option(select_elements[0], match_schedule[entry_num - 1].teams[3].teamNumber); + set_option(select_elements[1], match_schedule[entry_num - 1].teams[4].teamNumber); + set_option(select_elements[2], match_schedule[entry_num - 1].teams[5].teamNumber); + } +} +function advance_screen(screen) { + let panes = document.getElementsByClassName("main_pane"); + for (var i = 0; i < 4; i++) { + panes[i].style.display = screen === i ? "unset" : "none"; + } +} +document.body.onload = init; diff --git a/static/public/js/main/main.ts b/static/public/js/main/main.ts new file mode 100644 index 00000000..6f70e6f6 --- /dev/null +++ b/static/public/js/main/main.ts @@ -0,0 +1,85 @@ +import { _get } from "../_modules/get/get.min.js"; + +// get events +const API_META = "/api/v1/data"; +const API_MATCHES = ["/api/v1/events/matches/", /* season */ "/", /* event */ "/qual/true"]; +const API_WHOAMI = "/api/v1/whoami"; + +var match_schedule; + +function init() { + load_events(); + try { + load_matches((document.getElementById("event_code") as HTMLSelectElement).value); + match_num_entry((document.getElementById("match_num_input") as HTMLSelectElement).value); + } catch {} + + (document.getElementById("event_code") as HTMLSelectElement).addEventListener("change", () => { + load_matches((document.getElementById("event_code") as HTMLSelectElement).value); + }); + (document.getElementById("match_num_input") as HTMLSelectElement).addEventListener("change", () => { + match_num_entry((document.getElementById("match_num_input") as HTMLSelectElement).value) + }); + + let adv_buttons = document.getElementsByClassName("continue_button") as HTMLCollectionOf; + for (var i = 0; i < 3; i++) { + adv_buttons[i].addEventListener("click", () => { + advance_screen(i + 1); + }); + } +} + +function load_events() { + _get(API_META, null).then((result) => { + result.events.forEach(event_code => { + (document.getElementById("event_code") as HTMLSelectElement).insertAdjacentHTML("beforeend", ``); + }); + }).catch((error) => { + alert(`failed to load valid event codes. ${error}`); + }); +} + +function load_matches(event: String) { + _get(API_MATCHES[0] + "2023" + API_MATCHES[1] + event + API_MATCHES[2], null).then((result) => { + if (result.Schedule.length != 0) { + match_schedule = result.Schedule; + } else { + alert("match schedule is not yet posted") + match_schedule = []; + } + }).catch((error) => { + alert(`failed to load matches. ${error}`) + match_schedule = []; + }) +} + +function set_option(element: HTMLOptionElement, value: string) { + element.innerText = value; + element.value = value; +} + +function match_num_entry(entry: String) { + let entry_num = Number(entry); + if (entry_num > match_schedule.length || entry_num < 1) { + (document.getElementById("bad_match_num") as HTMLSpanElement).innerText = `invalid. must be between 1 and ${match_schedule.length}`; + (document.getElementById("bad_match_num") as HTMLSpanElement).style.display = "unset"; + } else { + (document.getElementById("bad_match_num") as HTMLSpanElement).style.display = "none"; + let select_elements: HTMLCollectionOf = document.getElementsByClassName("teamNumOption") as HTMLCollectionOf; + set_option(select_elements[3], match_schedule[entry_num - 1].teams[0].teamNumber); + set_option(select_elements[4], match_schedule[entry_num - 1].teams[1].teamNumber); + set_option(select_elements[5], match_schedule[entry_num - 1].teams[2].teamNumber); + set_option(select_elements[0], match_schedule[entry_num - 1].teams[3].teamNumber); + set_option(select_elements[1], match_schedule[entry_num - 1].teams[4].teamNumber); + set_option(select_elements[2], match_schedule[entry_num - 1].teams[5].teamNumber); + } +} + +function advance_screen(screen: Number) { + let panes = document.getElementsByClassName("main_pane") as HTMLCollectionOf; + for (var i = 0; i < 4; i++) { + panes[i].style.display = screen === i ? "unset" : "none"; + } +} + +document.body.onload = init \ No newline at end of file diff --git a/static/scouts.html b/static/scouts.html index 5e026a50..2b411cfb 100644 --- a/static/scouts.html +++ b/static/scouts.html @@ -5,7 +5,7 @@ Scouts - bearTracks - + diff --git a/static/settings.html b/static/settings.html index 4ce20f19..1f26120c 100644 --- a/static/settings.html +++ b/static/settings.html @@ -5,7 +5,7 @@ Settings - bearTracks - + diff --git a/static/spin.html b/static/spin.html index ab56eb54..1c2f80ac 100644 --- a/static/spin.html +++ b/static/spin.html @@ -5,13 +5,13 @@ Spinner - bearTracks - + - +