commit 37fd54c634b3034581f4c78adfc3a455a3e3d441 Author: Dani Date: Mon Oct 26 19:48:54 2026 +0700 Fix Semua Bismillah siap lulus diff --git a/facerecognition_presensi/.gitignore b/facerecognition_presensi/.gitignore new file mode 100644 index 0000000..223876d --- /dev/null +++ b/facerecognition_presensi/.gitignore @@ -0,0 +1,60 @@ +# Local secrets (examples are intentionally public) +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +*.pfx +*.jks +*.keystore +!AndroidFolder/android/app/debug.keystore +local.properties +google-services.json +GoogleService-Info.plist +# Dependencies and build output +node_modules/ +venv/ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.expo/ +.gradle/ +.cxx/ +build/ +dist/ +*.apk +*.aab +*.log +# Personal datasets and generated biometric templates +Dataset/ +face_mahasiswa/ +datasets/ +uploads/ +trained_models/ +*.pkl +*.pickle +*.npy +*.npz +*.index +*.db +*.sqlite* +*.sql +face_gallery_faiss* +threshold_calibration.json +# Reports, backups, local agent state +*.zip +*.rar +*.bak +.DS_Store +Thumbs.db +.vscode/ +.idea/ +.agent/ +.agents/ +.claude/ +.codex/ +graphify-out/ + +cleanup-manifest.json diff --git a/facerecognition_presensi/AndroidFolder/.env.example b/facerecognition_presensi/AndroidFolder/.env.example new file mode 100644 index 0000000..a53c97b --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/.env.example @@ -0,0 +1 @@ +EXPO_PUBLIC_API_URL=http://10.0.2.2:8000 diff --git a/facerecognition_presensi/AndroidFolder/.gitignore b/facerecognition_presensi/AndroidFolder/.gitignore new file mode 100644 index 0000000..ec0d3eb --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/.gitignore @@ -0,0 +1,43 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +app-example + +# generated native folders +/ios + diff --git a/facerecognition_presensi/AndroidFolder/README.md b/facerecognition_presensi/AndroidFolder/README.md new file mode 100644 index 0000000..48dd63f --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/README.md @@ -0,0 +1,50 @@ +# Welcome to your Expo app 👋 + +This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). + +## Get started + +1. Install dependencies + + ```bash + npm install + ``` + +2. Start the app + + ```bash + npx expo start + ``` + +In the output, you'll find options to open the app in a + +- [development build](https://docs.expo.dev/develop/development-builds/introduction/) +- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) +- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) +- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo + +You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). + +## Get a fresh project + +When you're ready, run: + +```bash +npm run reset-project +``` + +This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. + +## Learn more + +To learn more about developing your project with Expo, look at the following resources: + +- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). +- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. + +## Join the community + +Join our community of developers creating universal apps. + +- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. +- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. diff --git a/facerecognition_presensi/AndroidFolder/android/.gitignore b/facerecognition_presensi/AndroidFolder/android/.gitignore new file mode 100644 index 0000000..8a6be07 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/.gitignore @@ -0,0 +1,16 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# Bundle artifacts +*.jsbundle diff --git a/facerecognition_presensi/AndroidFolder/android/app/build.gradle b/facerecognition_presensi/AndroidFolder/android/app/build.gradle new file mode 100644 index 0000000..d877ca3 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/build.gradle @@ -0,0 +1,182 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() + // Use Expo CLI to bundle the app, this ensures the Metro config + // works correctly with Expo projects. + cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization). + */ +def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean() + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace 'com.danye41.AndroidFolder' + defaultConfig { + applicationId 'com.danye41.AndroidFolder' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0.0" + + buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' + shrinkResources enableShrinkResources.toBoolean() + minifyEnabled enableMinifyInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true' + crunchPngs enablePngCrunchInRelease.toBoolean() + } + } + packagingOptions { + jniLibs { + def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false' + useLegacyPackaging enableLegacyPackaging.toBoolean() + } + } + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } +} + +// Apply static values from `gradle.properties` to the `android.packagingOptions` +// Accepts values in comma delimited lists, example: +// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini +["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop -> + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(","); + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; + def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; + def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; + + if (isGifEnabled) { + // For animated gif support + implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/facerecognition_presensi/AndroidFolder/android/app/debug.keystore b/facerecognition_presensi/AndroidFolder/android/app/debug.keystore new file mode 100644 index 0000000..364e105 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/debug.keystore differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/proguard-rules.pro b/facerecognition_presensi/AndroidFolder/android/app/proguard-rules.pro new file mode 100644 index 0000000..551eb41 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# react-native-reanimated +-keep class com.swmansion.reanimated.** { *; } +-keep class com.facebook.react.turbomodule.** { *; } + +# Add any project specific keep options here: diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/debug/AndroidManifest.xml b/facerecognition_presensi/AndroidFolder/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/debugOptimized/AndroidManifest.xml b/facerecognition_presensi/AndroidFolder/android/app/src/debugOptimized/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/debugOptimized/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/AndroidManifest.xml b/facerecognition_presensi/AndroidFolder/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..063ae45 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/java/com/danye41/AndroidFolder/MainActivity.kt b/facerecognition_presensi/AndroidFolder/android/app/src/main/java/com/danye41/AndroidFolder/MainActivity.kt new file mode 100644 index 0000000..cdc0d23 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/java/com/danye41/AndroidFolder/MainActivity.kt @@ -0,0 +1,65 @@ +package com.danye41.AndroidFolder +import expo.modules.splashscreen.SplashScreenManager + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + // setTheme(R.style.AppTheme); + // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af + SplashScreenManager.registerOnActivity(this) + // @generated end expo-splashscreen + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/java/com/danye41/AndroidFolder/MainApplication.kt b/facerecognition_presensi/AndroidFolder/android/app/src/main/java/com/danye41/AndroidFolder/MainApplication.kt new file mode 100644 index 0000000..093740e --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/java/com/danye41/AndroidFolder/MainApplication.kt @@ -0,0 +1,56 @@ +package com.danye41.AndroidFolder + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.common.ReleaseLevel +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint +import com.facebook.react.defaults.DefaultReactNativeHost + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + } + ) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + DefaultNewArchitectureEntryPoint.releaseLevel = try { + ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) + } catch (e: IllegalArgumentException) { + ReleaseLevel.STABLE + } + loadReactNative(this) + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 0000000..31df827 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 0000000..ef243aa Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 0000000..e9d5474 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..d61da15 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..4aeed11 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable/ic_launcher_background.xml b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..883b2a0 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable/rn_edit_text_material.xml b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..5c25e72 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..9764d2a --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..9764d2a --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..a858de0 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp new file mode 100644 index 0000000..c02225f Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..49a260f Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..fc1087b Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..7b9ac97 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..c75dbb9 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp new file mode 100644 index 0000000..1e9b3d1 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..46ea7d0 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..12a3278 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a9b305d Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..b2658c0 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..dde133a Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..671b75f Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..2809d31 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..535e6d7 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..858ddc8 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..55a3581 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ba86612 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..9dbe470 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..357395c Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..d31ce8f Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..d190cdd Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..3468026 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..8736a27 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4185f23 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values-night/colors.xml b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..5429410 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,3 @@ + + #000000 + \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values/colors.xml b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..dd82deb --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #ffffff + #E6F4FE + #023c69 + #ffffff + \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values/strings.xml b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..194b0b3 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + AndroidFolder + automatic + contain + false + \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values/styles.xml b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..45a97e6 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/android/build.gradle b/facerecognition_presensi/AndroidFolder/android/build.gradle new file mode 100644 index 0000000..0554dd1 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/build.gradle @@ -0,0 +1,24 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath('com.android.tools.build:gradle') + classpath('com.facebook.react:react-native-gradle-plugin') + classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} + +apply plugin: "expo-root-project" +apply plugin: "com.facebook.react.rootproject" diff --git a/facerecognition_presensi/AndroidFolder/android/gradle.properties b/facerecognition_presensi/AndroidFolder/android/gradle.properties new file mode 100644 index 0000000..8e39f82 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/gradle.properties @@ -0,0 +1,65 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false + +# Specifies whether the app is configured to use edge-to-edge via the app config or plugin +# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge. +expo.edgeToEdgeEnabled=true diff --git a/facerecognition_presensi/AndroidFolder/android/gradle/wrapper/gradle-wrapper.jar b/facerecognition_presensi/AndroidFolder/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/facerecognition_presensi/AndroidFolder/android/gradle/wrapper/gradle-wrapper.properties b/facerecognition_presensi/AndroidFolder/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/facerecognition_presensi/AndroidFolder/android/gradlew b/facerecognition_presensi/AndroidFolder/android/gradlew new file mode 100644 index 0000000..7f94d3d --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/facerecognition_presensi/AndroidFolder/android/gradlew.bat b/facerecognition_presensi/AndroidFolder/android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/facerecognition_presensi/AndroidFolder/android/settings.gradle b/facerecognition_presensi/AndroidFolder/android/settings.gradle new file mode 100644 index 0000000..44b2b49 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/android/settings.gradle @@ -0,0 +1,39 @@ +pluginManagement { + def reactNativeGradlePlugin = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + }.standardOutput.asText.get().trim() + ).getParentFile().absolutePath + includeBuild(reactNativeGradlePlugin) + + def expoPluginsPath = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") + }.standardOutput.asText.get().trim(), + "../android/expo-gradle-plugin" + ).absolutePath + includeBuild(expoPluginsPath) +} + +plugins { + id("com.facebook.react.settings") + id("expo-autolinking-settings") +} + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) + } +} +expoAutolinking.useExpoModules() + +rootProject.name = 'AndroidFolder' + +expoAutolinking.useExpoVersionCatalog() + +include ':app' +includeBuild(expoAutolinking.reactNativeGradlePlugin) diff --git a/facerecognition_presensi/AndroidFolder/app.json b/facerecognition_presensi/AndroidFolder/app.json new file mode 100644 index 0000000..44091c7 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app.json @@ -0,0 +1,62 @@ +{ + "expo": { + "name": "AndroidFolder", + "slug": "AndroidFolder", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "androidfolder", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#E6F4FE", + "foregroundImage": "./assets/images/android-icon-foreground.png", + "backgroundImage": "./assets/images/android-icon-background.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false, + "permissions": [ + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_FINE_LOCATION" + ], + "package": "com.danye41.AndroidFolder" + }, + "web": { + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-location", + { + "locationAlwaysAndWhenInUsePermission": "Aplikasi membutuhkan akses lokasi untuk validasi radius presensi." + } + ], + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "backgroundColor": "#000000" + } + } + ], + "expo-secure-store" + ], + "experiments": { + "typedRoutes": true, + "reactCompiler": true + } + } +} diff --git a/facerecognition_presensi/AndroidFolder/app/(tabs)/_layout.tsx b/facerecognition_presensi/AndroidFolder/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..e9bb118 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/(tabs)/_layout.tsx @@ -0,0 +1,69 @@ +import { Tabs } from 'expo-router'; +import React from 'react'; + +import { HapticTab } from '@/components/haptic-tab'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import { Colors } from '@/constants/theme'; +import { useColorScheme } from '@/hooks/use-color-scheme'; + +export default function TabLayout() { + const colorScheme = useColorScheme(); + + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + + , + }} + /> + + + ); +} diff --git a/facerecognition_presensi/AndroidFolder/app/(tabs)/explore.tsx b/facerecognition_presensi/AndroidFolder/app/(tabs)/explore.tsx new file mode 100644 index 0000000..71518f9 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/(tabs)/explore.tsx @@ -0,0 +1,112 @@ +import { Image } from 'expo-image'; +import { Platform, StyleSheet } from 'react-native'; + +import { Collapsible } from '@/components/ui/collapsible'; +import { ExternalLink } from '@/components/external-link'; +import ParallaxScrollView from '@/components/parallax-scroll-view'; +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import { Fonts } from '@/constants/theme'; + +export default function TabTwoScreen() { + return ( + + }> + + + Explore + + + This app includes example code to help you get started. + + + This app has two screens:{' '} + app/(tabs)/index.tsx and{' '} + app/(tabs)/explore.tsx + + + The layout file in app/(tabs)/_layout.tsx{' '} + sets up the tab navigator. + + + Learn more + + + + + You can open this project on Android, iOS, and the web. To open the web version, press{' '} + w in the terminal running this project. + + + + + For static images, you can use the @2x and{' '} + @3x suffixes to provide files for + different screen densities + + + + Learn more + + + + + This template has light and dark mode support. The{' '} + useColorScheme() hook lets you inspect + what the user's current color scheme is, and so you can adjust UI colors accordingly. + + + Learn more + + + + + This template includes an example of an animated component. The{' '} + components/HelloWave.tsx component uses + the powerful{' '} + + react-native-reanimated + {' '} + library to create a waving hand animation. + + {Platform.select({ + ios: ( + + The components/ParallaxScrollView.tsx{' '} + component provides a parallax effect for the header image. + + ), + })} + + + ); +} + +const styles = StyleSheet.create({ + headerImage: { + color: '#808080', + bottom: -90, + left: -35, + position: 'absolute', + }, + titleContainer: { + flexDirection: 'row', + gap: 8, + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/app/(tabs)/face_recognition.tsx b/facerecognition_presensi/AndroidFolder/app/(tabs)/face_recognition.tsx new file mode 100644 index 0000000..6e6a63e --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/(tabs)/face_recognition.tsx @@ -0,0 +1,1636 @@ +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Alert, + Dimensions, + Platform, + StatusBar as RNStatusBar, + ScrollView, + Image, +} from "react-native"; +import { CameraView, CameraType, useCameraPermissions } from "expo-camera"; +import { StatusBar } from "expo-status-bar"; +import { Ionicons } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import * as ImageManipulator from "expo-image-manipulator"; +import * as ImagePicker from "expo-image-picker"; +import { WebView } from "react-native-webview"; +import { useTheme } from "@/contexts/ThemeContext"; + +const CAMERA_SIZE = Dimensions.get("window").width * 0.85; +const OVAL_WIDTH = CAMERA_SIZE * 0.55; +const OVAL_HEIGHT = CAMERA_SIZE * 0.75; +const OVAL_TOP = (CAMERA_SIZE - OVAL_HEIGHT) / 2; +const OVAL_LEFT = (CAMERA_SIZE - OVAL_WIDTH) / 2; + +export type ScanStatus = "idle" | "scanning" | "detected" | "success" | "failed"; + +export interface FaceScanResult { + success: boolean; + message: string; + nama_mahasiswa?: string; + nim?: string; + status_presensi?: string; + confidence?: number; + lbph_score?: number; + cnn_score?: number; + is_known?: boolean; + waktu_presensi?: string; + inference_time_ms?: number; +} + +export interface FaceRecognitionProps { + token: string; + idPertemuan: number; + namaMatakuliah: string; + pertemuanKe: number; + modeLokasi: "bebas" | "wajib"; + userLocation: { latitude: number; longitude: number } | null; + geolokasiData: { latitude: number; longitude: number; radius_meter: number; nama_lokasi: string; } | null; + isMockLocation: boolean; + locationLoading: boolean; + API_BASE: string; + onBack: () => void; + onPresensiSuccess: () => void; + scanStatus: ScanStatus; + setScanStatus: React.Dispatch>; + capturedImage: string | null; + setCapturedImage: React.Dispatch>; + showPreview: boolean; + setShowPreview: React.Dispatch>; + cameraRef: React.RefObject; + facing: CameraType; + setFacing: React.Dispatch>; + isCapturing: boolean; + setIsCapturing: React.Dispatch>; + faceScanResult: FaceScanResult | null; + setFaceScanResult: React.Dispatch>; + isGalleryImage: boolean; + setIsGalleryImage: React.Dispatch>; + galleryBase64: string | null; + setGalleryBase64: React.Dispatch>; +} + +const THEME = { + bg: "#FFFFFF", + bgSecondary: "#F8FAFC", + bgCard: "#FFFFFF", + bgInput: "#F1F5F9", + border: "#E2E8F0", + borderLight: "#F1F5F9", + textPrimary: "#1E293B", + textSecondary: "#64748B", + textMuted: "#94A3B8", + primary: "#2563EB", + primaryLight: "#3B82F6", + primaryDark: "#1D4ED8", + primaryBg: "#EFF6FF", + success: "#10B981", + successBg: "#ECFDF5", + warning: "#F59E0B", + warningBg: "#FFFBEB", + danger: "#EF4444", + dangerBg: "#FEF2F2", + shadow: "rgba(0, 0, 0, 0.06)", +}; + + +interface LeafletMapProps { + userLocation: { latitude: number; longitude: number } | null; + geolokasiData: { + latitude: number; + longitude: number; + radius_meter: number; + nama_lokasi: string; + } | null; + distanceToCenter: number | null; +} + + +function LeafletMap({ userLocation, geolokasiData, distanceToCenter }: LeafletMapProps) { + const mapWebViewRef = useRef(null); + + + const getMapHTML = useCallback(() => { + const centerLat = geolokasiData?.latitude ?? userLocation?.latitude ?? -6.2088; + const centerLng = geolokasiData?.longitude ?? userLocation?.longitude ?? 106.8456; + + let geoMarker = ''; + let circles = ''; + let userMarkerInit = ''; + + if (userLocation) { + userMarkerInit = + `var userMarker = L.marker([${userLocation.latitude}, ${userLocation.longitude}], { + icon: L.divIcon({ + className: 'custom-marker', + html: '
', + iconSize: [14, 14], + iconAnchor: [7, 7] + }) + }).addTo(map).bindPopup('Posisi Anda'); + var userAccuracyCircle = L.circle([${userLocation.latitude}, ${userLocation.longitude}], { + radius: 8, + color: '#2563EB', + fillColor: '#2563EB', + fillOpacity: 0.15, + weight: 1 + }).addTo(map);`; + } else { + userMarkerInit = `var userMarker = null; var userAccuracyCircle = null;`; + } + + if (geolokasiData) { + geoMarker = `L.marker([${geolokasiData.latitude}, ${geolokasiData.longitude}], { + icon: L.divIcon({ + className: 'custom-marker', + html: '
', + iconSize: [16, 16], + iconAnchor: [8, 8] + }) + }).addTo(map).bindPopup('${geolokasiData.nama_lokasi}');`; + + circles = `L.circle([${geolokasiData.latitude}, ${geolokasiData.longitude}], { + radius: ${geolokasiData.radius_meter}, + color: '#EF4444', + fillColor: '#EF4444', + fillOpacity: 0.12, + weight: 2 + }).addTo(map);`; + } + + return ` + + + + + + + + + +
+ + +`; + }, [geolokasiData, userLocation]); // FIX: added userLocation to deps + + // Send real-time location updates to WebView without re-rendering HTML + useEffect(() => { + if (userLocation && mapWebViewRef.current) { + mapWebViewRef.current.postMessage(JSON.stringify({ + type: 'updateUserLocation', + lat: userLocation.latitude, + lng: userLocation.longitude, + })); + } + }, [userLocation]); + + return ( + + + + {distanceToCenter !== null && geolokasiData ? ( + distanceToCenter <= geolokasiData.radius_meter ? ( + + ✅ Dalam Radius ({Math.round(distanceToCenter)}m dari pusat) + + ) : ( + + ⚠️ Di Luar Radius – Tidak bisa presensi + + ) + ) : ( + + Memuat data lokasi... + + )} + + + ); +} + +// ─── Oval Mask Overlay Component ───────────────────────────────────────────── + +// ─── Oval Mask Overlay Component ───────────────────────────────────────────── + +function OvalMaskOverlay() { + return ; +} + + +// ─── Main Component ────────────────────────────────────────────────────────────── + +export default function FaceRecognition(props: FaceRecognitionProps) { + const [permission, requestPermission] = useCameraPermissions(); + const { isDarkMode, theme: themeContext } = useTheme(); + + // ── State tambahan untuk low-light handling ───────────────────────────── + const [isTorchOn, setIsTorchOn] = useState(false); + const [lowLightWarning, setLowLightWarning] = useState(false); + const retryCountRef = useRef(0); // Jumlah auto-retry yang sudah dilakukan + + const colors = { + bg: themeContext.bg, + bgCard: themeContext.cardBg, + bgInput: isDarkMode ? "#1E293B" : "#F1F5F9", + border: themeContext.border, + borderLight: themeContext.divider, + textPrimary: themeContext.textPrimary, + textSecondary: themeContext.textSecondary, + textMuted: themeContext.textMuted, + primary: themeContext.accent, + primaryLight: isDarkMode ? "#60A5FA" : "#3B82F6", + primaryBg: themeContext.accentLight, + success: isDarkMode ? "#34D399" : "#10B981", + warning: isDarkMode ? "#FBBF24" : "#F59E0B", + danger: isDarkMode ? "#F87171" : "#EF4444", + }; + + const { + token, + idPertemuan, + namaMatakuliah, + pertemuanKe, + modeLokasi, + userLocation, + geolokasiData, + isMockLocation, + locationLoading, + API_BASE, + onBack, + onPresensiSuccess, + scanStatus, + setScanStatus, + capturedImage, + setCapturedImage, + showPreview, + setShowPreview, + cameraRef, + facing, + isCapturing, + setIsCapturing, + faceScanResult, + setFaceScanResult, + isGalleryImage, + setIsGalleryImage, + galleryBase64, + setGalleryBase64, + } = props; + + const getScanStatusColor = (): string => { + switch (scanStatus) { + case "scanning": return colors.primary; + case "detected": return "#10B981"; + case "success": return colors.success; + case "failed": return colors.danger; + default: return colors.primary; + } + }; + + const getScanStatusText = (): string => { + switch (scanStatus) { + case "scanning": return "Memindai wajah..."; + case "detected": return "Wajah terdeteksi!"; + case "success": return "✓ Presensi Berhasil!"; + case "failed": return "✗ Wajah tidak dikenali"; + default: return "Posisikan wajah dalam oval"; + } + }; + + const haversineDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => { + const R = 6_371_000; + const phi1 = (lat1 * Math.PI) / 180; + const phi2 = (lat2 * Math.PI) / 180; + const dPhi = ((lat2 - lat1) * Math.PI) / 180; + const dLambda = ((lon2 - lon1) * Math.PI) / 180; + const a = Math.sin(dPhi / 2) ** 2 + Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + }; + + const muteShutterSound = async () => { + if (Platform.OS === "ios") { + try { + const AudioSession = require("expo-av").Audio; + await AudioSession.setAudioModeAsync({ playsInSilentModeIOS: true, staysActiveInBackground: false }); + } catch { } + } + }; + + const blobToBase64 = (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64 = (reader.result as string).split(",")[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }; + + const cropToOvalRegion = async (photoUri: string, photoWidth: number, photoHeight: number, isFront: boolean): Promise => { + try { + // CameraView uses a cover-style preview: the source image is scaled + // uniformly until it fills the square camera window, then the excess + // pixels are clipped from the centre. Mapping with independent scaleX / + // scaleY made the crop drift away from the oval on portrait/landscape + // photos, even when the user's face was positioned correctly. + const coverScale = Math.max(CAMERA_SIZE / photoWidth, CAMERA_SIZE / photoHeight); + const renderedWidth = photoWidth * coverScale; + const renderedHeight = photoHeight * coverScale; + const renderedOffsetX = (CAMERA_SIZE - renderedWidth) / 2; + const renderedOffsetY = (CAMERA_SIZE - renderedHeight) / 2; + const ovalLeft = (CAMERA_SIZE - OVAL_WIDTH) / 2; + const ovalTop = (CAMERA_SIZE - OVAL_HEIGHT) / 2; + // Keep useful context around the face for YuNet landmark alignment. + const cropPadding = CAMERA_SIZE * 0.16; + const previewLeft = Math.max(0, ovalLeft - cropPadding); + const previewTop = Math.max(0, ovalTop - cropPadding); + const previewRight = Math.min(CAMERA_SIZE, ovalLeft + OVAL_WIDTH + cropPadding); + const previewBottom = Math.min(CAMERA_SIZE, ovalTop + OVAL_HEIGHT + cropPadding); + + const toPhotoX = (x: number) => (x - renderedOffsetX) / coverScale; + const toPhotoY = (y: number) => (y - renderedOffsetY) / coverScale; + let scaledCropLeft = Math.floor(toPhotoX(previewLeft)); + let scaledCropTop = Math.floor(toPhotoY(previewTop)); + let scaledCropRight = Math.ceil(toPhotoX(previewRight)); + let scaledCropBottom = Math.ceil(toPhotoY(previewBottom)); + + // Clamp after mapping because the preview may extend beyond the source + // image on one axis. + scaledCropLeft = Math.max(0, Math.min(photoWidth - 1, scaledCropLeft)); + scaledCropTop = Math.max(0, Math.min(photoHeight - 1, scaledCropTop)); + scaledCropRight = Math.max(scaledCropLeft + 1, Math.min(photoWidth, scaledCropRight)); + scaledCropBottom = Math.max(scaledCropTop + 1, Math.min(photoHeight, scaledCropBottom)); + const scaledCropWidth = scaledCropRight - scaledCropLeft; + const scaledCropHeight = scaledCropBottom - scaledCropTop; + + const actions: ImageManipulator.Action[] = []; + actions.push({ crop: { originX: scaledCropLeft, originY: scaledCropTop, width: scaledCropWidth, height: scaledCropHeight } }); + // Mirror only after cropping. Mirroring first changes the crop origin + // and can select the opposite side of the source photo. + if (isFront) { + actions.push({ flip: ImageManipulator.FlipType.Horizontal }); + } + actions.push({ resize: { width: 800 } }); + + const manipResult = await ImageManipulator.manipulateAsync( + photoUri, + actions, + { compress: 0.95, format: ImageManipulator.SaveFormat.JPEG } + ); + return manipResult.uri; + } catch (error) { + console.warn("Crop failed, using original:", error); + return photoUri; + } + }; + + const handleCapture = async (isAutoRetry: boolean = false) => { + if (isCapturing && !isAutoRetry) return; + if (!token) { + Alert.alert("Error", "Sesi login tidak valid. Silahkan login ulang."); + return; + } + + setIsCapturing(true); + setScanStatus("scanning"); + if (!isAutoRetry) { + setFaceScanResult(null); + retryCountRef.current = 0; + } + + try { + let finalBase64: string; + + if (isGalleryImage && galleryBase64) { + // ── Gambar dari Galeri ────────────────────────────────────────── + // FIX: Resize gambar galeri agar konsisten dengan gambar kamera. + // Sebelumnya gambar galeri langsung dikirim tanpa preprocessing, + // menyebabkan ukuran tidak konsisten dengan foto kamera (resize 800px). + try { + const galleryUri = capturedImage; + if (galleryUri) { + const resizedGallery = await ImageManipulator.manipulateAsync( + galleryUri, + [{ resize: { width: 800 } }], + { compress: 0.95, format: ImageManipulator.SaveFormat.JPEG } + ); + const resizedBlob = await (await fetch(resizedGallery.uri)).blob(); + finalBase64 = await blobToBase64(resizedBlob); + } else { + finalBase64 = galleryBase64; + } + } catch { + // Fallback ke base64 galeri asli jika resize gagal + finalBase64 = galleryBase64; + } + } else { + // ── Foto dari Kamera ──────────────────────────────────────────── + if (!cameraRef.current) { + throw new Error("Kamera tidak tersedia"); + } + await muteShutterSound(); + + const photo = await cameraRef.current.takePictureAsync({ + quality: 1, base64: true, imageType: "jpg" as any, shutterSound: false, + ...(Platform.OS === "ios" && { mute: true }), + }); + + if (!photo?.base64) { + throw new Error("Gagal mengambil foto dari kamera"); + } + + setCapturedImage(photo.uri); + setShowPreview(true); + setScanStatus("detected"); + await new Promise((r) => setTimeout(r, 300)); + + // Jangan crop di Android. Lakukan resize + re-encode saja untuk + // menormalkan EXIF orientation. cv2.imdecode di backend membaca + // piksel JPEG tanpa menerapkan EXIF orientation; mengirim base64 asli + // dapat membuat foto terlihat tegak di Android tetapi terbaca miring + // oleh YuNet. + try { + const normalized = await ImageManipulator.manipulateAsync( + photo.uri, + [{ resize: { width: 1200 } }], + { compress: 0.95, format: ImageManipulator.SaveFormat.JPEG } + ); + const normalizedBlob = await (await fetch(normalized.uri)).blob(); + finalBase64 = await blobToBase64(normalizedBlob); + } catch { + // Fallback only if the platform cannot re-encode the photo. + finalBase64 = photo.base64; + } + } + + const res = await fetch(`${API_BASE}/presensi/face-scan`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ id_pertemuan: idPertemuan, foto_base64: finalBase64, latitude: userLocation?.latitude ?? null, longitude: userLocation?.longitude ?? null }), + }); + + let result: FaceScanResult; + try { + result = await res.json(); + } catch { + throw new Error(`Server error: HTTP ${res.status}`); + } + + if (res.ok && result.success) { + setScanStatus("success"); + setFaceScanResult(result); + setLowLightWarning(false); + retryCountRef.current = 0; + const confPct = result.confidence ? `${(result.confidence * 100).toFixed(1)}%` : ""; + const statusLabel = result.status_presensi === "terlambat" ? "Terlambat" : "Hadir"; + + Alert.alert( + `Berhasil ${statusLabel}!`, + `${result.nama_mahasiswa ?? ""} (${result.nim ?? ""})\n\nPresensi tercatat sebagai ${statusLabel}\nSkor kepercayaan: ${confPct}\nWaktu deteksi: ${result.inference_time_ms?.toFixed(0) ?? 0}ms`, + [{ text: "OK", onPress: () => { setScanStatus("idle"); setFaceScanResult(null); setCapturedImage(null); setShowPreview(false); setIsGalleryImage(false); setGalleryBase64(null); onPresensiSuccess(); } }], + ); + } else if ( + res.ok && + !result.success && + result.is_known === true && + result.message?.toLowerCase().includes("sudah melakukan presensi") + ) { + // Backend: presensi sudah tercatat sebelumnya. + // Treat sebagai success — redirect ke students view & disable tombol scan. + setScanStatus("success"); + setLowLightWarning(false); + retryCountRef.current = 0; + Alert.alert( + "Sudah Tercatat", + "Presensi Anda untuk sesi ini sudah tercatat sebelumnya.", + [{ text: "OK", onPress: () => { setScanStatus("idle"); setFaceScanResult(null); setCapturedImage(null); setShowPreview(false); setIsGalleryImage(false); setGalleryBase64(null); onPresensiSuccess(); } }], + ); + } else { + // Gagal — tampilkan error normal + setScanStatus("failed"); + setLowLightWarning(false); + retryCountRef.current = 0; + const msg = result.message ?? (res.ok ? "Wajah tidak dikenali" : `HTTP ${res.status}`); + // Tampilkan skor similarity agar mudah di-debug + const confPct = result.confidence != null + ? `\nSkor kesamaan: ${(result.confidence * 100).toFixed(1)}% (threshold ~45%)` + : ""; + + Alert.alert("Gagal", `${msg}${confPct}`, [{ text: "Coba Lagi", onPress: () => { setScanStatus("idle"); setCapturedImage(null); setShowPreview(false); setIsGalleryImage(false); setGalleryBase64(null); } }]); + } + } catch (err: unknown) { + setScanStatus("failed"); + retryCountRef.current = 0; + const errMsg = err instanceof Error ? err.message : "Terjadi kesalahan tidak diketahui"; + Alert.alert("Error", errMsg.includes("face-scan") ? "ML service tidak tersedia. Pastikan server ML berjalan di port 8001." : errMsg, [{ text: "Coba Lagi", onPress: () => { setScanStatus("idle"); setCapturedImage(null); setShowPreview(false); setIsGalleryImage(false); setGalleryBase64(null); } }]); + } finally { + setIsCapturing(false); + } + }; + + // ─── Handle Manual Input (NIM-based) ────────────────────────────────────── + const handleManualInput = async (nim: string) => { + if (!token) { + Alert.alert("Error", "Sesi login tidak valid. Silahkan login ulang."); + return; + } + + setIsCapturing(true); + setScanStatus("scanning"); + + try { + const res = await fetch(`${API_BASE}/presensi/manual`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id_pertemuan: idPertemuan, + nim: nim, + latitude: userLocation?.latitude ?? null, + longitude: userLocation?.longitude ?? null, + }), + }); + + const result = await res.json(); + + if (res.ok && result.success) { + setScanStatus("success"); + const statusLabel = result.status_presensi === "terlambat" ? "Terlambat" : "Hadir"; + Alert.alert( + `Berhasil ${statusLabel}!`, + `${result.nama_mahasiswa ?? ""} (${result.nim ?? ""})\n\nPresensi tercatat sebagai ${statusLabel}`, + [{ text: "OK", onPress: () => { setScanStatus("idle"); onPresensiSuccess(); } }], + ); + } else { + setScanStatus("failed"); + Alert.alert("Gagal", result.message ?? "Gagal melakukan presensi manual", [ + { text: "Coba Lagi", onPress: () => { setScanStatus("idle"); } }, + ]); + } + } catch (err) { + setScanStatus("failed"); + Alert.alert("Error", "Terjadi kesalahan koneksi", [ + { text: "OK", onPress: () => { setScanStatus("idle"); } }, + ]); + } finally { + setIsCapturing(false); + } + }; + + // ─── Calculate distance & radius status for scan view ── + const scanDistanceToCenter = + userLocation && geolokasiData + ? haversineDistance( + userLocation.latitude, + userLocation.longitude, + geolokasiData.latitude, + geolokasiData.longitude, + ) + : null; + + const scanIsInRadius = + !isMockLocation && + (modeLokasi === "bebas" || + locationLoading || + (userLocation !== null && + geolokasiData !== null && + scanDistanceToCenter !== null && + scanDistanceToCenter <= geolokasiData.radius_meter)); + + // Tombol scan aktif jika: ada gambar galeri siap kirim, atau kamera siap & dalam radius + const captureDisabled = isCapturing || isMockLocation || (!isGalleryImage && (showPreview || !scanIsInRadius)); + + // ─── Permission check ── + if (!permission) { + return ( + + + Memuat Kamera... + + ); + } + + if (!permission.granted) { + return ( + + + + Izin Kamera Diperlukan + + Aplikasi membutuhkan akses kamera untuk mengenali wajah Anda dalam proses presensi. + + + Izinkan Kamera + + + + ); + } + + const frameColor = getScanStatusColor(); + + return ( + + + + + { + setScanStatus("idle"); + setCapturedImage(null); + setShowPreview(false); + onBack(); + }} + > + + + + Scan Wajah + + {namaMatakuliah} · ke-{pertemuanKe} + + + + {/* Torch / Flash Button — untuk kondisi low-light indoor */} + setIsTorchOn((v) => !v)} + > + + + {/* Gallery Button */} + { + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: true, + aspect: [OVAL_WIDTH, OVAL_HEIGHT], + quality: 1, + base64: true, + }); + + if (!result.canceled && result.assets[0]) { + const asset = result.assets[0]; + setCapturedImage(asset.uri); + setShowPreview(true); + setScanStatus("detected"); + // Tandai bahwa gambar berasal dari galeri & simpan base64-nya + setIsGalleryImage(true); + setGalleryBase64(asset.base64 ?? null); + } + } catch (err) { + Alert.alert("Error", "Gagal memilih gambar dari galeri"); + } + }} + > + + + {/* Manual Input Button */} + { + Alert.prompt( + "Input Manual", + "Masukkan NIM untuk presensi manual:", + async (nim) => { + if (!nim || nim.trim() === "") { + Alert.alert("Error", "NIM tidak boleh kosong"); + return; + } + await handleManualInput(nim.trim()); + }, + "plain-text", + "", + "number-pad" + ); + }} + > + + + + + + + {/* Camera Area clipped to the oval */} + + + {showPreview && capturedImage ? ( + + ) : ( + + )} + + + {/* Oval border ring */} + + + {showPreview && ( + + + + + {capturedImage?.startsWith("file://") || capturedImage?.startsWith("/") ? "Dari Galeri" : "Tertangkap"} + + + + )} + + + {/* Status Badge */} + + + + {getScanStatusText()} + + + + {/* Mock GPS Warning in Scan View */} + {isMockLocation && ( + + + + + ⛔ GPS Palsu Terdeteksi! + + + Matikan aplikasi fake GPS dan gunakan GPS bawaan HP. Tombol scan dinonaktifkan. + + + + )} + + {/* Low-Light Warning Banner */} + {lowLightWarning && !isTorchOn && ( + + + + 💡 Pencahayaan Kurang + + Nyalakan senter (⚡) di header atau cari area lebih terang + + + setIsTorchOn(true)} + style={styles.torchQuickBtn} + > + + Nyalakan + + + )} + + {/* Torch Active Banner */} + {isTorchOn && ( + + + Senter aktif — mode pencahayaan rendah + + )} + + {/* ── Peta Lokasi ── */} + + {modeLokasi === "bebas" && ( + + + 🔓 Presensi Bebas – Dapat presensi dari mana saja + + + )} + + {/* Info Panel */} + + + + Pastikan wajah terlihat jelas dan pencahayaan cukup + + + + + Di ruangan gelap? Nyalakan ⚡ senter di kanan atas + + + + + Lepas kacamata atau masker jika ada + + + + Pilih dari Galeri atau Input Manual + + + + + {/* Capture Button – dinonaktifkan jika di luar radius (mode wajib) */} + + {modeLokasi === "wajib" && !scanIsInRadius && !locationLoading && ( + + ⚠️ Anda di luar radius – Tidak dapat presensi + + )} + handleCapture()} + disabled={captureDisabled} + activeOpacity={0.8} + > + + {isCapturing ? ( + + ) : isMockLocation ? ( + + ) : isGalleryImage ? ( + + ) : showPreview ? ( + + ) : !scanIsInRadius ? ( + + ) : ( + + )} + + + {isCapturing + ? "Memproses..." + : isMockLocation + ? "GPS Palsu" + : isGalleryImage + ? "Scan Gambar Galeri" + : showPreview + ? "Tercapture" + : !scanIsInRadius + ? "Di Luar Radius" + : "Scan Wajah"} + + + + + ); +} + +// ─── Styles ───────────────────────────────────────────────────────────────── + +// ─── Styles ───────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: THEME.bg, + }, + + header: { + flexDirection: "row", + alignItems: "center", + paddingTop: Platform.OS === "android" ? (RNStatusBar.currentHeight ?? 40) : 55, + paddingHorizontal: 20, + paddingBottom: 14, + backgroundColor: THEME.bg, + }, + headerCenter: { + flex: 1, + marginLeft: 12, + }, + headerTitle: { + fontSize: 22, + fontWeight: "800", + color: THEME.textPrimary, + letterSpacing: 0.3, + }, + headerSubtitle: { + fontSize: 12, + color: THEME.primary, + fontWeight: "500", + marginTop: 2, + }, + backButton: { + width: 40, + height: 40, + borderRadius: 10, + backgroundColor: THEME.bgInput, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: THEME.border, + }, + iconButton: { + width: 40, + height: 40, + borderRadius: 10, + backgroundColor: THEME.bgInput, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: THEME.border, + }, + headerRightButtons: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + manualInputButton: { + marginLeft: 4, + }, + + demoBanner: { + flexDirection: "row", + alignItems: "center", + gap: 6, + marginHorizontal: 20, + marginBottom: 8, + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: THEME.warningBg, + borderRadius: 8, + borderWidth: 1, + borderColor: "#FDE68A", + }, + demoBannerText: { + fontSize: 12, + color: THEME.warning, + fontWeight: "500", + }, + + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 20, + paddingBottom: 32, + gap: 14, + }, + centerFill: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 12, + }, + loadingText: { + fontSize: 14, + color: THEME.textSecondary, + marginTop: 8, + }, + emptyTitle: { + fontSize: 16, + fontWeight: "600", + color: THEME.textSecondary, + textAlign: "center", + }, + emptySubtitle: { + fontSize: 13, + color: THEME.textMuted, + textAlign: "center", + paddingHorizontal: 32, + }, + + sessionCard: { + backgroundColor: THEME.bgCard, + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: THEME.border, + gap: 12, + shadowColor: THEME.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 1, + shadowRadius: 8, + elevation: 2, + }, + sessionChip: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 20, + gap: 6, + }, + sessionChipDot: { + width: 7, + height: 7, + borderRadius: 4, + }, + sessionChipText: { + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.3, + }, + sessionCardBody: { + flexDirection: "row", + alignItems: "center", + }, + sessionName: { + fontSize: 16, + fontWeight: "700", + color: THEME.textPrimary, + marginBottom: 4, + }, + sessionMeta: { + fontSize: 12, + color: THEME.textSecondary, + marginBottom: 4, + }, + sessionDateRow: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + sessionDate: { + fontSize: 12, + color: THEME.textMuted, + }, + statsRow: { + flexDirection: "row", + gap: 8, + }, + statsPill: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 20, + }, + statsPillText: { + fontSize: 12, + fontWeight: "600", + }, + + summaryBar: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginHorizontal: 20, + marginBottom: 12, + paddingHorizontal: 14, + paddingVertical: 10, + backgroundColor: THEME.bgCard, + borderRadius: 12, + borderWidth: 1, + borderColor: THEME.border, + }, + summaryText: { + fontSize: 13, + color: THEME.textSecondary, + }, + progressTrack: { + flex: 1, + height: 4, + backgroundColor: THEME.borderLight, + borderRadius: 2, + overflow: "hidden", + }, + progressFill: { + height: 4, + backgroundColor: THEME.success, + borderRadius: 2, + }, + summaryPct: { + fontSize: 12, + color: THEME.success, + fontWeight: "700", + minWidth: 34, + textAlign: "right", + }, + studentList: { + flex: 1, + }, + studentListContent: { + paddingHorizontal: 20, + paddingBottom: 96, + gap: 8, + }, + studentRow: { + flexDirection: "row", + alignItems: "center", + backgroundColor: THEME.bgCard, + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: THEME.borderLight, + gap: 12, + }, + avatarCircle: { + width: 42, + height: 42, + borderRadius: 21, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + }, + avatarInitials: { + fontSize: 14, + fontWeight: "700", + }, + studentInfo: { + flex: 1, + }, + studentNim: { + fontSize: 11, + color: THEME.textMuted, + marginBottom: 2, + }, + studentName: { + fontSize: 14, + fontWeight: "600", + color: THEME.textPrimary, + }, + attendanceBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 20, + borderWidth: 1, + }, + attendanceBadgeText: { + fontSize: 11, + fontWeight: "600", + }, + + bottomBar: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 20, + paddingVertical: 14, + paddingBottom: Platform.OS === "android" ? 14 : 28, + backgroundColor: THEME.bg, + borderTopWidth: 1, + borderTopColor: THEME.border, + gap: 12, + }, + scanButton: { + flex: 1, + borderRadius: 12, + overflow: "hidden", + }, + scanButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + paddingVertical: 14, + borderRadius: 12, + }, + scanButtonText: { + fontSize: 15, + fontWeight: "700", + color: "#fff", + }, + + permissionContainer: { + flex: 1, + backgroundColor: THEME.bg, + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + permissionCard: { + alignItems: "center", + backgroundColor: THEME.bgCard, + borderRadius: 24, + padding: 32, + borderWidth: 1, + borderColor: THEME.border, + width: "100%", + shadowColor: THEME.shadow, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 1, + shadowRadius: 16, + elevation: 4, + }, + permissionTitle: { + fontSize: 22, + fontWeight: "700", + color: THEME.textPrimary, + marginTop: 16, + marginBottom: 10, + textAlign: "center", + }, + permissionDesc: { + fontSize: 14, + color: THEME.textSecondary, + textAlign: "center", + lineHeight: 22, + marginBottom: 28, + }, + permissionButton: { + backgroundColor: THEME.primary, + paddingHorizontal: 32, + paddingVertical: 14, + borderRadius: 12, + }, + permissionButtonText: { + color: "#fff", + fontSize: 16, + fontWeight: "700", + }, + + // Camera area — clipped to the oval shape + cameraWrapper: { + alignSelf: "center", + width: CAMERA_SIZE, + height: CAMERA_SIZE, + marginTop: 4, + position: "relative", + borderRadius: 24, + backgroundColor: "#FFFFFF", + }, + ovalClip: { + position: "absolute", + top: OVAL_TOP, + left: OVAL_LEFT, + width: OVAL_WIDTH, + height: OVAL_HEIGHT, + borderRadius: OVAL_WIDTH / 2, + overflow: "hidden", + backgroundColor: THEME.bgSecondary, + }, + cameraInner: { + position: "absolute", + top: -OVAL_TOP, + left: -OVAL_LEFT, + width: CAMERA_SIZE, + height: CAMERA_SIZE, + }, + ovalRing: { + position: "absolute", + top: OVAL_TOP, + left: OVAL_LEFT, + width: OVAL_WIDTH, + height: OVAL_HEIGHT, + borderRadius: OVAL_WIDTH / 2, + borderWidth: 3, + borderColor: "#FFFFFF", + zIndex: 10, + }, + + scanLine: { + position: "absolute", + left: 0, + right: 0, + height: 2, + backgroundColor: "rgba(37, 99, 235, 0.8)", + shadowColor: THEME.primary, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 1, + shadowRadius: 8, + elevation: 5, + zIndex: 20, + }, + glowOverlay: { + position: "absolute", + top: (CAMERA_SIZE - OVAL_HEIGHT) / 2 - 6, + left: (CAMERA_SIZE - OVAL_WIDTH) / 2 - 6, + width: OVAL_WIDTH + 12, + height: OVAL_HEIGHT + 12, + borderRadius: (OVAL_WIDTH + 12) / 2, + borderWidth: 3, + zIndex: 5, + }, + + freezeFrameOverlay: { + position: "absolute", + top: 12, + right: 12, + zIndex: 30, + }, + freezeFrameLabel: { + flexDirection: "row", + alignItems: "center", + gap: 6, + backgroundColor: "rgba(255, 255, 255, 0.95)", + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 20, + borderWidth: 1, + borderColor: THEME.border, + }, + freezeFrameLabelText: { + fontSize: 11, + color: THEME.primary, + fontWeight: "600", + }, + + statusWrapper: { + alignItems: "center", + marginTop: 18, + paddingHorizontal: 24, + }, + statusBadge: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 50, + borderWidth: 1, + gap: 8, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + statusText: { + fontSize: 14, + fontWeight: "600", + }, + + infoPanel: { + marginHorizontal: 20, + marginTop: 14, + backgroundColor: THEME.bgCard, + borderRadius: 16, + padding: 14, + borderWidth: 1, + borderColor: THEME.border, + gap: 10, + }, + infoPanelRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + infoPanelText: { + fontSize: 13, + color: THEME.textSecondary, + flex: 1, + lineHeight: 18, + }, + + scanScrollView: { + flex: 1, + }, + scanScrollContent: { + paddingBottom: 140, + }, + captureSection: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + alignItems: "center", + paddingTop: 16, + paddingBottom: Platform.OS === "android" ? 20 : 32, + backgroundColor: THEME.bg, + borderTopWidth: 1, + borderTopColor: THEME.border, + }, + captureButton: { + alignItems: "center", + gap: 8, + }, + captureButtonDisabled: { + opacity: 0.6, + }, + captureGradient: { + width: 72, + height: 72, + borderRadius: 36, + alignItems: "center", + justifyContent: "center", + shadowColor: THEME.primary, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + }, + captureText: { + fontSize: 13, + fontWeight: "600", + color: THEME.textPrimary, + }, + + leafletMapContainer: { + marginHorizontal: 16, + marginBottom: 8, + borderRadius: 12, + overflow: "hidden", + borderWidth: 1, + borderColor: THEME.border, + height: 200, + backgroundColor: THEME.bgSecondary, + }, + locationStatus: { + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: THEME.bgCard, + borderTopWidth: 1, + borderTopColor: THEME.border, + }, + locationStatusText: { + fontSize: 13, + color: THEME.textSecondary, + fontWeight: "500", + }, + locationFreeBanner: { + marginHorizontal: 16, + marginBottom: 8, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: THEME.successBg, + borderRadius: 8, + borderWidth: 1, + borderColor: "#A7F3D0", + }, + locationFreeText: { + fontSize: 13, + color: THEME.success, + fontWeight: "500", + }, + scanButtonDisabled: { + opacity: 0.6, + }, + outsideRadiusHint: { + fontSize: 12, + color: THEME.danger, + fontWeight: "600", + marginBottom: 8, + textAlign: "center", + }, + mockGpsBanner: { + flexDirection: "row" as const, + alignItems: "center" as const, + gap: 10, + marginHorizontal: 16, + marginBottom: 8, + padding: 14, + backgroundColor: "#DC2626", + borderRadius: 12, + }, + mockGpsBannerTitle: { + fontSize: 14, + fontWeight: "700" as const, + color: "#fff", + }, + mockGpsBannerDesc: { + fontSize: 12, + color: "rgba(255,255,255,0.85)", + marginTop: 2, + }, + + // ── Low-Light Banner Styles ─────────────────────────────────────────────── + lowLightBanner: { + flexDirection: "row" as const, + alignItems: "center" as const, + gap: 10, + marginHorizontal: 16, + marginBottom: 8, + padding: 12, + backgroundColor: "#FEF3C7", + borderRadius: 12, + borderWidth: 1, + borderColor: "#FDE68A", + }, + lowLightBannerTitle: { + fontSize: 13, + fontWeight: "700" as const, + color: "#92400E", + marginBottom: 2, + }, + lowLightBannerDesc: { + fontSize: 12, + color: "#78350F", + }, + torchActiveBanner: { + flexDirection: "row" as const, + alignItems: "center" as const, + gap: 8, + marginHorizontal: 16, + marginBottom: 6, + paddingHorizontal: 12, + paddingVertical: 7, + backgroundColor: "#FFFBEB", + borderRadius: 8, + borderWidth: 1, + borderColor: "#FDE68A", + }, + torchActiveBannerText: { + fontSize: 12, + color: "#92400E", + fontWeight: "500" as const, + }, + torchQuickBtn: { + flexDirection: "row" as const, + alignItems: "center" as const, + gap: 4, + paddingHorizontal: 10, + paddingVertical: 6, + backgroundColor: "#FDE68A", + borderRadius: 8, + }, + torchQuickBtnText: { + fontSize: 12, + fontWeight: "700" as const, + color: "#92400E", + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/app/(tabs)/index.tsx b/facerecognition_presensi/AndroidFolder/app/(tabs)/index.tsx new file mode 100644 index 0000000..28cb639 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/(tabs)/index.tsx @@ -0,0 +1,1427 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { StatusBar } from "expo-status-bar"; +import { router } from "expo-router"; + +import { ThemedText } from "@/components/themed-text"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTheme } from "@/contexts/ThemeContext"; +import { API_BASE } from "@/constants/config"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface JadwalItem { + id_jadwal: number; + hari: string; + jam_mulai: string; + jam_selesai: string; + ruangan: string | null; + status: string; + kode_matakuliah: string | null; + nama_matakuliah: string | null; + pengajar_list: Array<{ nama_dosen: string | null }>; + kelas_list: Array<{ + id_prodi: number; + id_angkatan: number; + id_golongan: number; + }>; +} + +interface HistoryItem { + id_history: number; + status_presensi: "hadir" | "terlambat" | "izin" | "sakit" | "alpha" | null; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const HARI_MAP: Record = { + "0": "Minggu", + "1": "Senin", + "2": "Selasa", + "3": "Rabu", + "4": "Kamis", + "5": "Jumat", + "6": "Sabtu", +}; + +const MONTHS_ID = [ + "Januari", + "Februari", + "Maret", + "April", + "Mei", + "Juni", + "Juli", + "Agustus", + "September", + "Oktober", + "November", + "Desember", +]; + +function getTodayHari(): string { + return HARI_MAP[String(new Date().getDay())]; +} + +function formatTodayDate(): string { + const d = new Date(); + return `${HARI_MAP[String(d.getDay())]}, ${d.getDate()} ${MONTHS_ID[d.getMonth()]} ${d.getFullYear()}`; +} + +function fmtTime(t: string | null): string { + if (!t) return "--:--"; + return t.substring(0, 5); +} + +function getScheduleStatus(jamMulai: string, jamSelesai: string): string { + const now = new Date(); + const nowMin = now.getHours() * 60 + now.getMinutes(); + const parseMin = (t: string) => { + const parts = t.split(":").map(Number); + return parts[0] * 60 + (parts[1] ?? 0); + }; + const mulaiMin = parseMin(jamMulai); + const selesaiMin = parseMin(jamSelesai); + if (nowMin >= mulaiMin && nowMin < selesaiMin) return "Berlangsung"; + if (nowMin < mulaiMin) return "Akan Datang"; + return "Selesai"; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export default function HomeScreen() { + const { user, token } = useAuth(); + const { isDarkMode, theme } = useTheme(); + const isDark = isDarkMode; + + // ── State ────────────────────────────────────────────────────────────────── + const [jadwalList, setJadwalList] = useState([]); + const [loadingJadwal, setLoadingJadwal] = useState(false); + const [historyList, setHistoryList] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + // ── Theme Palette (Dominan Putih & Biru Muda di Light Mode) ─────────────── + const colors = { + bg: isDark ? theme.bg : "#F0F9FF", // Light: Soft sky-blue white background + card: isDark ? theme.cardBg : "#FFFFFF", // Light: Pure White cards + cardBorder: isDark ? theme.border : "#E0F2FE", // Light: Subtle light blue border + textPrimary: isDark ? theme.textPrimary : "#0F172A", + textSecondary: isDark ? theme.textSecondary : "#475569", + textMuted: isDark ? theme.textMuted : "#64748B", + primary: isDark ? "#38BDF8" : "#0EA5E9", // Sky Blue Accent + primaryDark: isDark ? "#0284C7" : "#0284C7", + primaryLight: isDark ? "rgba(14, 165, 233, 0.15)" : "#E0F2FE", + primaryTint: isDark ? "rgba(56, 189, 248, 0.08)" : "#F0F9FF", + border: isDark ? theme.border : "#E0F2FE", + divider: isDark ? theme.divider : "#F1F5F9", + shadow: isDark ? "rgba(0,0,0,0.35)" : "rgba(14, 165, 233, 0.12)", + gradientCard: isDark ? ["#1E3A5F", "#0284C7"] : ["#0284C7", "#38BDF8"], + quickActionBg: isDark ? "#1E293B" : "#FFFFFF", + ornament1: isDark ? "rgba(14,165,233,0.12)" : "#BAE6FD", + ornament2: isDark ? "rgba(56,189,248,0.06)" : "#E0F2FE", + }; + + // ── Fetch Jadwal Hari Ini ────────────────────────────────────────────────── + const fetchJadwal = useCallback(async () => { + if (!token) return; + setLoadingJadwal(true); + try { + const todayHari = getTodayHari(); + const res = await fetch( + `${API_BASE}/jadwal/?hari=${encodeURIComponent(todayHari)}&limit=100`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as JadwalItem[]; + + // Filter sesuai kelas mahasiswa + let filtered = data; + if (user?.id_prodi && user?.id_angkatan && user?.id_golongan) { + filtered = data.filter((j) => + j.kelas_list.some( + (k) => + k.id_prodi === user.id_prodi && + k.id_angkatan === user.id_angkatan && + k.id_golongan === user.id_golongan + ) + ); + } + // Urutkan berdasarkan jam_mulai + filtered.sort((a, b) => a.jam_mulai.localeCompare(b.jam_mulai)); + setJadwalList(filtered); + } catch (err) { + console.warn("fetchJadwal error:", err); + setJadwalList([]); + } finally { + setLoadingJadwal(false); + } + }, [token, user?.id_prodi, user?.id_angkatan, user?.id_golongan]); + + // ── Fetch Rekap Kehadiran ────────────────────────────────────────────────── + const fetchHistory = useCallback(async () => { + if (!token) return; + setLoadingHistory(true); + try { + const idMhs = user?.id_mahasiswa; + const url = idMhs + ? `${API_BASE}/history-presensi/?id_mahasiswa=${idMhs}&limit=500` + : `${API_BASE}/history-presensi/?limit=500`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as HistoryItem[]; + setHistoryList(Array.isArray(data) ? data : []); + } catch (err) { + console.warn("fetchHistory error:", err); + setHistoryList([]); + } finally { + setLoadingHistory(false); + } + }, [token, user?.id_mahasiswa]); + + useEffect(() => { + fetchJadwal(); + fetchHistory(); + }, [fetchJadwal, fetchHistory]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await Promise.all([fetchJadwal(), fetchHistory()]); + setRefreshing(false); + }, [fetchJadwal, fetchHistory]); + + // ── Computed Stats ───────────────────────────────────────────────────────── + const totalHadir = historyList.filter( + (h) => h.status_presensi === "hadir" || h.status_presensi === "terlambat" + ).length; + const totalSakit = historyList.filter((h) => h.status_presensi === "sakit").length; + const totalIzin = historyList.filter((h) => h.status_presensi === "izin").length; + const totalAlpha = historyList.filter((h) => h.status_presensi === "alpha").length; + const totalAll = historyList.length; + const persentaseKehadiran = + totalAll > 0 ? Math.round((totalHadir / totalAll) * 100) : 0; + + const hadirHariIni = jadwalList.filter((j) => { + const st = getScheduleStatus(j.jam_mulai, j.jam_selesai); + return st === "Selesai" || st === "Berlangsung"; + }).length; + + // ── Helpers ──────────────────────────────────────────────────────────────── + const getStatusColor = (status: string) => { + switch (status) { + case "Berlangsung": + return "#10B981"; + case "Akan Datang": + return "#F59E0B"; + case "Selesai": + return isDark ? "#94A3B8" : "#64748B"; + default: + return isDark ? "#94A3B8" : "#64748B"; + } + }; + + const getStatusBg = (status: string) => { + switch (status) { + case "Berlangsung": + return isDark ? "rgba(16,185,129,0.15)" : "#ECFDF5"; + case "Akan Datang": + return isDark ? "rgba(245,158,11,0.15)" : "#FFFBEB"; + case "Selesai": + return isDark ? "rgba(100,116,139,0.15)" : "#F1F5F9"; + default: + return isDark ? "rgba(100,116,139,0.15)" : "#F1F5F9"; + } + }; + + const getGreeting = () => { + const h = new Date().getHours(); + if (h < 11) return "Selamat Pagi ☀️"; + if (h < 15) return "Selamat Siang 🌤️"; + if (h < 18) return "Selamat Sore 🌇"; + return "Selamat Malam 🌙"; + }; + + return ( + + + + {/* Background Decorative Ornaments */} + + + + + + + + } + > + {/* Header Section */} + + + + {getGreeting()} + + + {user?.nama_mahasiswa ?? user?.name ?? "Mahasiswa"} + + + + + {user?.nama_kelas ?? user?.email ?? "Mahasiswa Aktif"} + + + + + router.push("/profile")} + > + + + + + + {/* Quick Action Buttons */} + + {/* + + router.push("/face_recognition")} + > + + + + + + Presensi Wajah + Scan & Absen + + + + */} + + {/* + router.push("/jadwal")} + > + + + + + Jadwal + + + + router.push("/presensi")} + > + + + + + Riwayat + + + */} + + + {/* Academic Summary Hero Card */} + + {/* + + + + + + + + + Kehadiran Semester Ini + + + {user?.nama_kelas ?? "Kelas Anda"} + + + + + {loadingHistory ? "--" : `${persentaseKehadiran}%`} + + + + + + + + + + + + + + + + + + Hadir Hari Ini + + + {hadirHariIni} / {jadwalList.length} Sesi + + + + + + + + + + + + + Total Pertemuan + + + {loadingHistory ? "--" : `${totalAll} Sesi`} + + + + + + */} + + {/* Rekap Kehadiran Breakdown */} + + + + Rekap Status Kehadiran + + router.push("/presensi")}> + + Lihat Detail → + + + + + {loadingHistory ? ( + + ) : ( + + {/* Hadir */} + + + + + + {totalHadir} + + + Hadir + + + + {/* Sakit */} + + + + + + {totalSakit} + + + Sakit + + + + {/* Izin */} + + + + + + {totalIzin} + + + Izin + + + + {/* Alpha */} + + + + + + {totalAlpha} + + + Alpha + + + + )} + + + {/* Jadwal Hari Ini Section */} + + + + + Jadwal Hari Ini + + + {formatTodayDate()} + + + router.push("/jadwal")}> + + Semua Jadwal → + + + + + {loadingJadwal ? ( + + + + Memuat jadwal kuliah... + + + ) : jadwalList.length === 0 ? ( + + + + + + Tidak Ada Jadwal Kuliah + + + Tidak ada perkuliahan untuk kelas Anda pada hari {getTodayHari()}. Nikmati waktu luang Anda! + + + ) : ( + jadwalList.map((item) => { + const status = getScheduleStatus(item.jam_mulai, item.jam_selesai); + const dosenName = + item.pengajar_list.length > 0 + ? item.pengajar_list.map((p) => p.nama_dosen ?? "-").join(", ") + : "-"; + const jamStr = `${fmtTime(item.jam_mulai)} - ${fmtTime(item.jam_selesai)}`; + const isOngoing = status === "Berlangsung"; + + return ( + + + + + + + + + {jamStr} + + + + + + + {status} + + + + + + + {item.nama_matakuliah ?? "Mata Kuliah"} + + {item.kode_matakuliah && ( + + + {item.kode_matakuliah} + + + )} + + + + + + + + + {dosenName} + + + + + + + + {item.ruangan ?? "Ruangan belum ditentukan"} + + + + + {/* Attendance Action or Status */} + {isOngoing ? ( + router.push("/face_recognition")} + > + + + + Presensi Sekarang + + + + ) : ( + + + + + + Kehadiran Mahasiswa + + + + {persentaseKehadiran}% + + + + + + + )} + + + ); + }) + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + + // Ornaments + ornamentContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 0, + overflow: "hidden", + }, + ornamentTopRight: { + position: "absolute", + top: -100, + right: -80, + width: 320, + height: 320, + borderRadius: 160, + }, + ornamentMidLeft: { + position: "absolute", + top: 340, + left: -70, + width: 170, + height: 170, + borderRadius: 85, + borderWidth: 16, + opacity: 0.25, + }, + ornamentBottomRight: { + position: "absolute", + bottom: -80, + right: -60, + width: 240, + height: 240, + borderRadius: 120, + opacity: 0.35, + }, + + // Header + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 20, + paddingTop: 56, + paddingBottom: 16, + zIndex: 1, + }, + headerLeft: { + flex: 1, + paddingRight: 12, + }, + headerGreeting: { + fontSize: 13, + fontWeight: "700", + letterSpacing: 0.3, + marginBottom: 2, + textTransform: "uppercase", + }, + headerName: { + fontSize: 22, + fontWeight: "800", + letterSpacing: -0.3, + marginBottom: 8, + }, + classBadge: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + gap: 6, + }, + classBadgeText: { + fontSize: 12, + fontWeight: "700", + }, + profileContainer: { + width: 52, + height: 52, + borderRadius: 26, + borderWidth: 2.5, + position: "relative", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 4, + }, + profileImage: { + width: "100%", + height: "100%", + borderRadius: 24, + }, + profileOnlineDot: { + position: "absolute", + bottom: 0, + right: 0, + width: 14, + height: 14, + borderRadius: 7, + backgroundColor: "#10B981", + borderWidth: 2, + borderColor: "#FFFFFF", + }, + + // Quick Action Buttons + quickActionSection: { + paddingHorizontal: 20, + marginBottom: 20, + zIndex: 1, + gap: 10, + }, + quickActionMainCard: { + borderRadius: 20, + overflow: "hidden", + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.12, + shadowRadius: 12, + elevation: 4, + }, + quickActionGradient: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 18, + paddingVertical: 14, + gap: 12, + }, + quickActionIconWrapperMain: { + width: 44, + height: 44, + borderRadius: 14, + backgroundColor: "rgba(255,255,255,0.22)", + justifyContent: "center", + alignItems: "center", + }, + quickActionTextContainer: { + flex: 1, + }, + quickActionTitleMain: { + color: "#FFFFFF", + fontSize: 16, + fontWeight: "800", + }, + quickActionSubMain: { + color: "rgba(255,255,255,0.85)", + fontSize: 12, + fontWeight: "500", + marginTop: 1, + }, + quickActionSubGroup: { + flexDirection: "row", + gap: 10, + }, + quickActionSubCard: { + flex: 1, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 14, + paddingVertical: 12, + borderRadius: 16, + borderWidth: 1, + gap: 10, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.04, + shadowRadius: 6, + elevation: 2, + }, + quickActionIconBg: { + width: 36, + height: 36, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + }, + quickActionSubTitle: { + fontSize: 13, + fontWeight: "700", + }, + + // Summary Card + summaryCardWrapper: { + paddingHorizontal: 20, + marginBottom: 20, + zIndex: 1, + }, + summaryCard: { + borderRadius: 24, + borderWidth: 1, + overflow: "hidden", + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.06, + shadowRadius: 16, + elevation: 4, + }, + summaryCardHeader: { + padding: 20, + position: "relative", + overflow: "hidden", + }, + cardHeaderCircleOverlay: { + position: "absolute", + right: -30, + top: -30, + width: 140, + height: 140, + borderRadius: 70, + backgroundColor: "rgba(255, 255, 255, 0.12)", + }, + summaryHeaderContent: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 16, + }, + summaryTitle: { + color: "rgba(255, 255, 255, 0.85)", + fontSize: 11, + fontWeight: "700", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + summarySubTitle: { + color: "#FFFFFF", + fontSize: 16, + fontWeight: "800", + marginTop: 2, + }, + percentBadge: { + backgroundColor: "rgba(255, 255, 255, 0.2)", + paddingHorizontal: 14, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.3)", + }, + summaryPercent: { + color: "#FFFFFF", + fontSize: 24, + fontWeight: "900", + }, + summaryProgressBarBg: { + height: 7, + backgroundColor: "rgba(255, 255, 255, 0.25)", + borderRadius: 4, + overflow: "hidden", + }, + summaryProgressBarFill: { + height: "100%", + backgroundColor: "#FFFFFF", + borderRadius: 4, + }, + summaryStatsRow: { + flexDirection: "row", + paddingVertical: 14, + paddingHorizontal: 16, + alignItems: "center", + justifyContent: "space-between", + }, + summaryStatItem: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + summaryStatIconBg: { + width: 36, + height: 36, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + }, + summaryStatLabel: { + fontSize: 11, + fontWeight: "500", + }, + summaryStatValue: { + fontSize: 13, + fontWeight: "700", + marginTop: 1, + }, + summaryStatDivider: { + width: 1, + height: 28, + marginHorizontal: 10, + }, + + // Recap + recapContainer: { + marginHorizontal: 20, + marginBottom: 20, + borderRadius: 24, + borderWidth: 1, + padding: 18, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.04, + shadowRadius: 10, + elevation: 3, + zIndex: 1, + }, + recapTitleRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 14, + }, + recapTitle: { + fontSize: 15, + fontWeight: "800", + }, + seeAllText: { + fontSize: 12, + fontWeight: "700", + }, + recapGrid: { + flexDirection: "row", + justifyContent: "space-between", + gap: 8, + }, + recapItem: { + flex: 1, + alignItems: "center", + paddingVertical: 12, + borderRadius: 16, + borderWidth: 1, + }, + recapIconWrapper: { + width: 34, + height: 34, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + marginBottom: 6, + }, + recapValue: { + fontSize: 17, + fontWeight: "800", + }, + recapLabel: { + fontSize: 11, + fontWeight: "600", + marginTop: 2, + }, + + // Schedule Section + scheduleSection: { + paddingHorizontal: 20, + paddingBottom: 20, + }, + sectionHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 14, + }, + sectionTitle: { + fontSize: 16, + fontWeight: "800", + }, + dateText: { + fontSize: 12, + fontWeight: "500", + marginTop: 1, + }, + + scheduleCardSimple: { + flexDirection: "row", + borderRadius: 20, + borderWidth: 1, + overflow: "hidden", + marginBottom: 14, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.04, + shadowRadius: 10, + elevation: 3, + }, + scheduleLeftStripe: { + width: 5, + }, + scheduleCardBody: { + flex: 1, + padding: 16, + }, + scheduleRowTop: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 10, + }, + timeContainerSimple: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + gap: 5, + }, + timeTextSimple: { + fontSize: 11, + fontWeight: "700", + }, + statusBadgeSimple: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + gap: 5, + }, + statusDotSimple: { + width: 6, + height: 6, + borderRadius: 3, + }, + statusTextSimple: { + fontSize: 10, + fontWeight: "800", + }, + courseHeaderSimple: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + marginBottom: 10, + gap: 8, + }, + courseNameSimple: { + flex: 1, + fontSize: 15, + fontWeight: "800", + lineHeight: 20, + }, + courseCodeBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + }, + courseCodeSimple: { + fontSize: 11, + fontWeight: "700", + }, + courseDetailsSimple: { + gap: 6, + marginBottom: 12, + borderBottomWidth: 1, + paddingBottom: 10, + }, + detailItemSimple: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + detailIconBg: { + width: 22, + height: 22, + borderRadius: 6, + justifyContent: "center", + alignItems: "center", + }, + detailTextSimple: { + flex: 1, + fontSize: 12, + fontWeight: "500", + }, + + // Presensi Now Button + presensiNowBtn: { + borderRadius: 12, + overflow: "hidden", + marginTop: 4, + }, + presensiNowGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 10, + gap: 8, + }, + presensiNowBtnText: { + color: "#FFFFFF", + fontSize: 13, + fontWeight: "800", + }, + + // Attendance Progress + attendanceSectionSimple: { + gap: 6, + }, + attendanceLabelRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + attendanceLeft: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + attendanceTextSimple: { + fontSize: 11, + fontWeight: "600", + }, + attendancePercentSimple: { + fontSize: 12, + fontWeight: "800", + }, + miniProgressBarSimple: { + height: 5, + borderRadius: 2.5, + overflow: "hidden", + }, + miniProgressFillSimple: { + height: "100%", + borderRadius: 2.5, + }, + + // Empty state card + emptyTabCard: { + borderRadius: 22, + borderWidth: 1, + padding: 24, + alignItems: "center", + justifyContent: "center", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.04, + shadowRadius: 8, + elevation: 2, + marginVertical: 6, + }, + emptyTabIconBg: { + width: 56, + height: 56, + borderRadius: 28, + justifyContent: "center", + alignItems: "center", + marginBottom: 14, + }, + emptyTabTitle: { + fontSize: 16, + fontWeight: "800", + marginBottom: 6, + textAlign: "center", + }, + emptyTabDesc: { + fontSize: 13, + textAlign: "center", + lineHeight: 18, + }, + bottomSpace: { + height: 30, + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/app/(tabs)/jadwal.tsx b/facerecognition_presensi/AndroidFolder/app/(tabs)/jadwal.tsx new file mode 100644 index 0000000..7053743 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/(tabs)/jadwal.tsx @@ -0,0 +1,922 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { + StyleSheet, + View, + Text, + TouchableOpacity, + ScrollView, + FlatList, + ActivityIndicator, + RefreshControl, + Dimensions, +} from "react-native"; +import { StatusBar } from "expo-status-bar"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { API_BASE } from "@/constants/config"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTheme } from "@/contexts/ThemeContext"; + +const { width } = Dimensions.get("window"); + +interface Kelas { + id: number; + nama_kelas: string; +} + +interface Pengajar { + nama_dosen: string; + role: string; +} + +interface JadwalItem { + id_jadwal: number; + hari: string; + jam_mulai: string; + jam_selesai: string; + ruangan: string; + nama_matakuliah: string; + kode_matakuliah: string; + semester: number; + kelas_list: Kelas[]; + pengajar_list: Pengajar[]; +} + +const DAYS = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"]; +const INDO_DAYS = [ + "Minggu", + "Senin", + "Selasa", + "Rabu", + "Kamis", + "Jumat", + "Sabtu", +]; + +// Mock data when offline/demo +const MOCK_SCHEDULES: Record = { + senin: [ + { + id_jadwal: 1, + hari: "senin", + jam_mulai: "08:00:00", + jam_selesai: "10:30:00", + ruangan: "Lab Komputer 3", + nama_matakuliah: "Pemrograman Mobile", + kode_matakuliah: "TI-401", + semester: 6, + kelas_list: [{ id: 1, nama_kelas: "TI-4A" }], + pengajar_list: [ + { nama_dosen: "Dr. Ahmad Santoso, M.Kom", role: "koordinator" }, + ], + }, + { + id_jadwal: 2, + hari: "senin", + jam_mulai: "13:00:00", + jam_selesai: "15:30:00", + ruangan: "Ruang 202", + nama_matakuliah: "Etika Profesi", + kode_matakuliah: "TI-405", + semester: 6, + kelas_list: [{ id: 2, nama_kelas: "TI-4A" }], + pengajar_list: [ + { nama_dosen: "Prof. Budi Wijaya, Ph.D", role: "koordinator" }, + ], + }, + ], + selasa: [ + { + id_jadwal: 3, + hari: "selasa", + jam_mulai: "08:00:00", + jam_selesai: "10:30:00", + ruangan: "Lab Komputer 1", + nama_matakuliah: "Basis Data Lanjut", + kode_matakuliah: "TI-402", + semester: 6, + kelas_list: [{ id: 3, nama_kelas: "TI-4A" }], + pengajar_list: [ + { nama_dosen: "Ir. Rina Lestari, M.T.", role: "koordinator" }, + ], + }, + { + id_jadwal: 4, + hari: "selasa", + jam_mulai: "10:45:00", + jam_selesai: "13:15:00", + ruangan: "Ruang 301", + nama_matakuliah: "Kecerdasan Buatan", + kode_matakuliah: "TI-403", + semester: 6, + kelas_list: [{ id: 4, nama_kelas: "TI-4A" }], + pengajar_list: [ + { nama_dosen: "Prof. Budi Wijaya, Ph.D", role: "koordinator" }, + ], + }, + ], + rabu: [ + { + id_jadwal: 5, + hari: "rabu", + jam_mulai: "08:00:00", + jam_selesai: "10:30:00", + ruangan: "Lab Jaringan 2", + nama_matakuliah: "Jaringan Komputer", + kode_matakuliah: "TI-404", + semester: 6, + kelas_list: [{ id: 5, nama_kelas: "TI-4A" }], + pengajar_list: [ + { nama_dosen: "Dr. Citra Dewi, M.T", role: "koordinator" }, + ], + }, + { + id_jadwal: 6, + hari: "rabu", + jam_mulai: "13:30:00", + jam_selesai: "16:00:00", + ruangan: "Lab Desain", + nama_matakuliah: "Interaksi Manusia & Komputer", + kode_matakuliah: "TI-406", + semester: 6, + kelas_list: [{ id: 6, nama_kelas: "TI-4A" }], + pengajar_list: [{ nama_dosen: "Haryono, M.Cs.", role: "koordinator" }], + }, + ], + kamis: [ + { + id_jadwal: 7, + hari: "kamis", + jam_mulai: "08:00:00", + jam_selesai: "10:30:00", + ruangan: "Ruang 103", + nama_matakuliah: "Rekayasa Perangkat Lunak", + kode_matakuliah: "TI-407", + semester: 6, + kelas_list: [{ id: 7, nama_kelas: "TI-4A" }], + pengajar_list: [ + { nama_dosen: "Dr. Ahmad Santoso, M.Kom", role: "koordinator" }, + ], + }, + ], + jumat: [ + { + id_jadwal: 8, + hari: "jumat", + jam_mulai: "09:00:00", + jam_selesai: "11:30:00", + ruangan: "Lab Jaringan 1", + nama_matakuliah: "Keamanan Informasi", + kode_matakuliah: "TI-408", + semester: 6, + kelas_list: [{ id: 8, nama_kelas: "TI-4A" }], + pengajar_list: [ + { nama_dosen: "Dr. Citra Dewi, M.T", role: "koordinator" }, + ], + }, + ], + sabtu: [], +}; + +export default function JadwalScreen() { + const { isDarkMode, theme } = useTheme(); + const { token } = useAuth(); + + // Get current day of week in Indonesian + const currentDayIndex = new Date().getDay(); + const todayIndoName = INDO_DAYS[currentDayIndex]; + const defaultDay = DAYS.includes(todayIndoName) ? todayIndoName : "Senin"; + + const [selectedDay, setSelectedDay] = useState(defaultDay); + const [allRawSchedules, setAllRawSchedules] = useState([]); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [isDemo, setIsDemo] = useState(false); + + const fetchJadwal = async (showLoadingIndicator = true) => { + if (showLoadingIndicator) setLoading(true); + if (!token) { + const mockFlat = Object.values(MOCK_SCHEDULES).flat(); + setAllRawSchedules(mockFlat); + setIsDemo(true); + setLoading(false); + setRefreshing(false); + return; + } + try { + const response = await fetch(`${API_BASE}/kelas-mata-kuliah/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = (await response.json()) as any[]; + const normalized = data.map((item) => ({ + ...item, + nama_matakuliah: item.nama_mk ?? item.nama_matakuliah ?? "", + kode_matakuliah: item.kode_mk ?? item.kode_matakuliah ?? "", + })) as JadwalItem[]; + setAllRawSchedules(normalized); + setIsDemo(false); + } catch { + const mockFlat = Object.values(MOCK_SCHEDULES).flat(); + setAllRawSchedules(mockFlat); + setIsDemo(true); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchJadwal(true); + }, []); + + const onRefresh = () => { + setRefreshing(true); + fetchJadwal(false); + }; + + // Filter schedules for the selected day + const schedulesForSelectedDay = useMemo(() => { + return allRawSchedules.filter( + (item) => item.hari?.toLowerCase() === selectedDay.toLowerCase() + ); + }, [allRawSchedules, selectedDay]); + + // Helper to detect if a class is currently active (today and within hours) + const isClassActive = (item: JadwalItem) => { + if (todayIndoName.toLowerCase() !== item.hari?.toLowerCase()) return false; + + const now = new Date(); + const [startH, startM] = item.jam_mulai.split(":").map(Number); + const [endH, endM] = item.jam_selesai.split(":").map(Number); + + const startTime = new Date(); + startTime.setHours(startH, startM, 0, 0); + + const endTime = new Date(); + endTime.setHours(endH, endM, 0, 0); + + return now >= startTime && now <= endTime; + }; + + return ( + + + + {/* Modern Curved Top Header */} + + {/* Decorative background shapes */} + + + + + + Jadwal Perkuliahan + + {selectedDay === todayIndoName + ? `Hari Ini • ${schedulesForSelectedDay.length} Mata Kuliah` + : `Hari ${selectedDay} • ${schedulesForSelectedDay.length} Mata Kuliah`} + + + + {isDemo && ( + + + Demo + + )} + + + + {/* Redesigned Day Selector Bar */} + + + {DAYS.map((day) => { + const isSelected = selectedDay === day; + const isToday = day === todayIndoName; + const dayClassCount = allRawSchedules.filter( + (item) => item.hari?.toLowerCase() === day.toLowerCase() + ).length; + + return ( + setSelectedDay(day)} + style={[ + styles.dayCard, + { + backgroundColor: isSelected + ? theme.accent + : theme.cardBg, + borderColor: isSelected + ? theme.accent + : isToday + ? theme.accent + : theme.border, + shadowColor: theme.shadowColor, + }, + ]} + activeOpacity={0.75} + > + {/* Header row inside day card (Day Name + Today Dot) */} + + + {day} + + {isToday && ( + + + Hari ini + + + )} + + + {/* Subtitle inside day card (Class count badge) */} + + 0 + ? theme.textSecondary + : theme.textMuted, + }, + ]} + > + {dayClassCount > 0 ? `${dayClassCount} Kelas` : "Libur"} + + + + ); + })} + + + + {/* Schedule List Section */} + {loading ? ( + + + + Memuat jadwal... + + + ) : ( + String(item.id_jadwal)} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + ListEmptyComponent={ + + + + + + Tidak Ada Perkuliahan + + + Hari {selectedDay} ini Anda bebas dari jadwal perkuliahan. + Nikmati waktu luang atau manfaatkan untuk belajar mandiri! + + + } + renderItem={({ item }) => { + const active = isClassActive(item); + const lecturer = + item.pengajar_list && item.pengajar_list.length > 0 + ? item.pengajar_list[0].nama_dosen + : "Dosen Pengampu"; + const className = + item.kelas_list && item.kelas_list.length > 0 + ? item.kelas_list[0].nama_kelas + : "Semua Kelas"; + + return ( + + {/* Active Indicator Accent Bar on the Left */} + {active && ( + + )} + + {/* Left Column: Timeline & Time Badges */} + + + {item.jam_mulai.substring(0, 5)} + + + {/* Vertical Connecting Line */} + + + + + + {item.jam_selesai.substring(0, 5)} + + + + {/* Right Column: Class Details Card */} + + + + + {className} + + + + {active ? ( + + + Berlangsung + + ) : ( + + Semester {item.semester} + + )} + + + + {item.nama_matakuliah} + + + Kode: {item.kode_matakuliah} + + + + + {/* Lecturer Row */} + + + + + + {lecturer} + + + + {/* Room Location Row */} + + + + + + Ruangan: {item.ruangan} + + + + + ); + }} + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + // Top Header Card + headerCard: { + height: 170, + paddingTop: 56, + paddingHorizontal: 24, + position: "relative", + overflow: "hidden", + borderBottomLeftRadius: 28, + borderBottomRightRadius: 28, + }, + circleOverlay1: { + width: 170, + height: 170, + borderRadius: 85, + opacity: 0.18, + position: "absolute", + top: -40, + right: -30, + }, + circleOverlay2: { + width: 120, + height: 120, + borderRadius: 60, + opacity: 0.12, + position: "absolute", + bottom: -20, + left: -20, + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + zIndex: 10, + }, + headerTitleGroup: { + flex: 1, + }, + headerTitle: { + fontSize: 23, + fontWeight: "800", + color: "#FFFFFF", + letterSpacing: -0.4, + }, + headerSubtitle: { + fontSize: 13.5, + marginTop: 4, + fontWeight: "500", + }, + demoBadge: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#FFFBEB", + borderColor: "#FDE68A", + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 12, + gap: 4, + }, + demoBadgeText: { + color: "#D97706", + fontSize: 11.5, + fontWeight: "700", + }, + // Redesigned Day Selector + daySelectorWrapper: { + marginTop: -32, + marginBottom: 12, + zIndex: 20, + }, + daySelectorScroll: { + paddingHorizontal: 20, + gap: 10, + paddingVertical: 6, + }, + dayCard: { + width: 110, + paddingVertical: 10, + paddingHorizontal: 12, + borderRadius: 18, + borderWidth: 1.5, + justifyContent: "space-between", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.08, + shadowRadius: 8, + elevation: 4, + }, + dayCardHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 8, + }, + dayCardName: { + fontSize: 14, + fontWeight: "800", + }, + todayBadge: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 8, + }, + todayBadgeText: { + fontSize: 9.5, + fontWeight: "800", + }, + countPill: { + alignSelf: "flex-start", + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 10, + }, + countPillText: { + fontSize: 11, + fontWeight: "700", + }, + // List Content + listContent: { + paddingHorizontal: 20, + paddingTop: 10, + paddingBottom: 40, + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + loadingText: { + marginTop: 10, + fontSize: 14, + fontWeight: "500", + }, + // Empty State Container + emptyContainer: { + alignItems: "center", + justifyContent: "center", + marginTop: 20, + padding: 30, + borderRadius: 24, + borderWidth: 1, + }, + emptyIconCircle: { + width: 72, + height: 72, + borderRadius: 36, + justifyContent: "center", + alignItems: "center", + marginBottom: 16, + }, + emptyTitle: { + fontSize: 18, + fontWeight: "800", + marginBottom: 8, + }, + emptySubtitle: { + fontSize: 13.5, + textAlign: "center", + lineHeight: 20, + }, + // Schedule Cards + scheduleCard: { + flexDirection: "row", + borderRadius: 22, + borderWidth: 1.5, + marginBottom: 16, + padding: 16, + position: "relative", + overflow: "hidden", + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.05, + shadowRadius: 10, + elevation: 3, + }, + activeAccentBar: { + position: "absolute", + left: 0, + top: 0, + bottom: 0, + width: 5, + }, + timelineColumn: { + width: 54, + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 4, + }, + timeText: { + fontSize: 13, + fontWeight: "800", + }, + timeTextMuted: { + fontSize: 12.5, + fontWeight: "600", + }, + timeLine: { + width: 2, + flex: 1, + marginVertical: 8, + alignItems: "center", + justifyContent: "center", + position: "relative", + }, + timelineDot: { + width: 7, + height: 7, + borderRadius: 3.5, + position: "absolute", + }, + cardContent: { + flex: 1, + marginLeft: 12, + }, + cardHeaderRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 6, + }, + classBadge: { + paddingHorizontal: 9, + paddingVertical: 4, + borderRadius: 9, + }, + classBadgeText: { + fontSize: 11.5, + fontWeight: "800", + }, + semesterText: { + fontSize: 11.5, + fontWeight: "600", + }, + activeBadge: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#ECFDF5", + paddingHorizontal: 9, + paddingVertical: 4, + borderRadius: 9, + gap: 4, + }, + activeDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: "#10B981", + }, + activeBadgeText: { + color: "#10B981", + fontSize: 11, + fontWeight: "800", + }, + courseName: { + fontSize: 16, + fontWeight: "800", + lineHeight: 22, + marginTop: 2, + }, + courseCode: { + fontSize: 12, + marginTop: 3, + fontWeight: "500", + }, + cardDivider: { + height: 1, + marginVertical: 12, + }, + detailRow: { + flexDirection: "row", + alignItems: "center", + marginTop: 6, + }, + detailIconBox: { + width: 24, + height: 24, + borderRadius: 7, + justifyContent: "center", + alignItems: "center", + marginRight: 8, + }, + detailText: { + fontSize: 12.5, + fontWeight: "600", + flex: 1, + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/app/(tabs)/presensi.tsx b/facerecognition_presensi/AndroidFolder/app/(tabs)/presensi.tsx new file mode 100644 index 0000000..43817c8 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/(tabs)/presensi.tsx @@ -0,0 +1,1870 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Alert, + Platform, + StatusBar as RNStatusBar, + ScrollView, + FlatList, + ActivityIndicator, + RefreshControl, + Modal, +} from "react-native"; +import { CameraView, CameraType } from "expo-camera"; +import { StatusBar } from "expo-status-bar"; +import { Ionicons } from "@expo/vector-icons"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTheme } from "@/contexts/ThemeContext"; +import * as Location from "expo-location"; +import { API_BASE } from "@/constants/config"; +import FaceRecognition, { FaceScanResult, ScanStatus } from "./face_recognition"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +type ViewMode = "list" | "scan" | "history"; +type MainTab = "presensi" | "riwayat"; +type AttendanceStatus = "belum" | "hadir" | "terlambat" | "izin" | "sakit" | "alpha"; +type HistoryStatus = "hadir" | "terlambat" | "izin" | "sakit" | "alpha"; + +interface Session { + batas_presensi: any; + id_pertemuan: number; + pertemuan_ke: number; + tanggal: string; + status_pertemuan: string; + nama_matakuliah: string; + kode_matakuliah: string; + total_mahasiswa: number; + sudah_hadir: number; + belum_presensi: number; + mode_lokasi: "bebas" | "wajib"; + id_geolokasi: number | null; +} + +interface HistoryItem { + id_history: number; + id_mahasiswa: number; + nim: string; + nama_mahasiswa: string; + id_pertemuan: number; + pertemuan_ke: number | null; + tanggal: string | null; + id_jadwal: number | null; + nama_mk: string; + kode_mk: string; + nama_kelas: string; + hari: string; + jam_mulai: string; + jam_selesai: string; + ruangan: string; + waktu_presensi: string | null; + status_presensi: HistoryStatus | null; + confidence_score: number | null; + lbph_score: number | null; + cnn_score: number | null; + metode_deteksi: string | null; + latitude_presensi: number | null; + longitude_presensi: number | null; + jarak_meter: number | null; + created_at: string | null; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const MONTHS_ID = [ + "Januari", "Februari", "Maret", "April", "Mei", "Juni", + "Juli", "Agustus", "September", "Oktober", "November", "Desember", +]; + +function formatDate(dateStr: string): string { + const d = new Date(dateStr); + return `${d.getDate()} ${MONTHS_ID[d.getMonth()]} ${d.getFullYear()}`; +} + +function formatDateTime(dateStr: string): string { + const d = new Date(dateStr); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getDate()} ${MONTHS_ID[d.getMonth()]} ${d.getFullYear()}, ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function formatTime(timeStr: string): string { + // timeStr bisa "08:00:00" atau ISO string + if (timeStr.includes("T")) { + const d = new Date(timeStr); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + return timeStr.substring(0, 5); +} + +function getInitials(name: string): string { + const words = name.trim().split(/\s+/); + if (words.length === 1) return words[0][0].toUpperCase(); + return (words[0][0] + words[words.length - 1][0]).toUpperCase(); +} + +const STATUS_MAP: Record = { + belum: { color: "#FFB300", label: "Belum Presensi" }, + hadir: { color: "#4CAF50", label: "Hadir" }, + terlambat: { color: "#FF9800", label: "Terlambat" }, + izin: { color: "#2196F3", label: "Izin" }, + sakit: { color: "#9C27B0", label: "Sakit" }, + alpha: { color: "#F44336", label: "Alpha" }, +}; + +const getHistoryStatusMap = ( + isDark: boolean +): Record< + HistoryStatus, + { color: string; bg: string; label: string; icon: string } +> => ({ + hadir: { + color: isDark ? "#34D399" : "#10B981", + bg: isDark ? "rgba(16, 185, 129, 0.15)" : "#ECFDF5", + label: "Hadir", + icon: "checkmark-circle", + }, + terlambat: { + color: isDark ? "#FBBF24" : "#F59E0B", + bg: isDark ? "rgba(245, 158, 11, 0.15)" : "#FFFBEB", + label: "Terlambat", + icon: "time", + }, + izin: { + color: isDark ? "#60A5FA" : "#3B82F6", + bg: isDark ? "rgba(59, 130, 246, 0.15)" : "#EFF6FF", + label: "Izin", + icon: "document-text", + }, + sakit: { + color: isDark ? "#C084FC" : "#8B5CF6", + bg: isDark ? "rgba(139, 92, 246, 0.15)" : "#F5F3FF", + label: "Sakit", + icon: "medical", + }, + alpha: { + color: isDark ? "#F87171" : "#EF4444", + bg: isDark ? "rgba(239, 68, 68, 0.15)" : "#FEF2F2", + label: "Alpha", + icon: "close-circle", + }, +}); + +function getSessionChip( + status: string, + isDark: boolean +): { + bg: string; + dot: string; + label: string; +} { + switch (status) { + case "sedang": + return { + bg: isDark ? "rgba(33, 150, 243, 0.15)" : "#E8F4FD", + dot: isDark ? "#60A5FA" : "#2196F3", + label: "Sedang Berlangsung", + }; + case "belum": + return { + bg: isDark ? "rgba(255, 179, 0, 0.15)" : "#FFF8E1", + dot: isDark ? "#FBBF24" : "#FFB300", + label: "Belum Mulai", + }; + default: + return { + bg: isDark ? "#334155" : "#F5F5F5", + dot: isDark ? "#94A3B8" : "#9E9E9E", + label: "Selesai", + }; + } +} + +// ─── Theme Colors Fallback ─────────────────────────────────────────────────── + +const THEME = { + bg: "#FFFFFF", + bgSecondary: "#F8FAFC", + bgCard: "#FFFFFF", + bgInput: "#F1F5F9", + border: "#E2E8F0", + borderLight: "#F1F5F9", + textPrimary: "#1E293B", + textSecondary: "#64748B", + textMuted: "#94A3B8", + primary: "#2563EB", + primaryLight: "#3B82F6", + primaryDark: "#1D4ED8", + primaryBg: "#EFF6FF", + success: "#10B981", + successBg: "#ECFDF5", + warning: "#F59E0B", + warningBg: "#FFFBEB", + danger: "#EF4444", + dangerBg: "#FEF2F2", + shadow: "rgba(0, 0, 0, 0.06)", +}; + +// ─── Component ────────────────────────────────────────────────────────────── + +export default function PresensiScreen() { + const { isDarkMode, theme: themeContext } = useTheme(); + + const colors = { + bg: themeContext.bg, + bgCard: themeContext.cardBg, + bgInput: isDarkMode ? "#1E293B" : "#F1F5F9", + border: themeContext.border, + borderLight: themeContext.divider, + textPrimary: themeContext.textPrimary, + textSecondary: themeContext.textSecondary, + textMuted: themeContext.textMuted, + primary: themeContext.accent, + primaryLight: isDarkMode ? "#60A5FA" : "#3B82F6", + primaryBg: themeContext.accentLight, + success: isDarkMode ? "#34D399" : "#10B981", + successBg: isDarkMode ? "rgba(16, 185, 129, 0.15)" : "#ECFDF5", + warning: isDarkMode ? "#FBBF24" : "#F59E0B", + warningBg: isDarkMode ? "rgba(245, 158, 11, 0.15)" : "#FFFBEB", + danger: isDarkMode ? "#F87171" : "#EF4444", + dangerBg: isDarkMode ? "rgba(239, 68, 68, 0.15)" : "#FEF2F2", + shadow: themeContext.shadowColor, + }; + + const [mainTab, setMainTab] = useState("presensi"); + const [view, setView] = useState("list"); + const { token, user } = useAuth(); + const [isDemo, setIsDemo] = useState(false); + const [sessions, setSessions] = useState([]); + const [loadingSessions, setLoadingSessions] = useState(false); + const [selectedSession, setSelectedSession] = useState(null); + const [checkingAttendance, setCheckingAttendance] = useState(null); // Track session yang sedang di-check + + // History state + const [historyList, setHistoryList] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + const [historyRefreshing, setHistoryRefreshing] = useState(false); + const [selectedHistory, setSelectedHistory] = useState(null); + const [showHistoryDetail, setShowHistoryDetail] = useState(false); + + // Face recognition state + const [scanStatus, setScanStatus] = useState("idle"); + const [isCapturing, setIsCapturing] = useState(false); + const [capturedImage, setCapturedImage] = useState(null); + const [showPreview, setShowPreview] = useState(false); + const cameraRef = useRef(null); + const [facing, setFacing] = useState("front"); + const [faceScanResult, setFaceScanResult] = useState(null); + const [isGalleryImage, setIsGalleryImage] = useState(false); + const [galleryBase64, setGalleryBase64] = useState(null); + + const [userLocation, setUserLocation] = useState<{ + latitude: number; + longitude: number; + } | null>(null); + const [geolokasiData, setGeolokasiData] = useState<{ + latitude: number; + longitude: number; + radius_meter: number; + nama_lokasi: string; + } | null>(null); + const [locationLoading, setLocationLoading] = useState(false); + const [isMockLocation, setIsMockLocation] = useState(false); + const locationWatchRef = useRef(null); + + useEffect(() => { + fetchSessions(); + }, [token]); + + // Fetch history saat tab riwayat dibuka + useEffect(() => { + if (mainTab === "riwayat") { + fetchHistory(); + } + }, [mainTab]); + + useEffect(() => { + return () => { + if (locationWatchRef.current) { + locationWatchRef.current.remove(); + locationWatchRef.current = null; + } + }; + }, []); + + useEffect(() => { + if (view !== "scan" || !selectedSession) { + if (locationWatchRef.current) { + locationWatchRef.current.remove(); + locationWatchRef.current = null; + } + return; + } + setUserLocation(null); + setGeolokasiData(null); + setIsMockLocation(false); + loadUserLocation(); + if (selectedSession.mode_lokasi === "wajib" && selectedSession.id_geolokasi) { + fetchGeolokasiData(selectedSession.id_geolokasi); + } + }, [view, selectedSession?.id_pertemuan]); + + const fetchSessions = async () => { + setLoadingSessions(true); + if (!token) { + setIsDemo(true); + setSessions([]); + setLoadingSessions(false); + return; + } + try { + const res = await fetch(`${API_BASE}/pertemuan/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.status === 401) { + console.warn("[fetchSessions] Auth error - token might be expired"); + throw new Error("401"); + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const all = (await res.json()) as Session[]; + + // Debug: log raw response untuk troubleshooting + console.log("[fetchSessions] Raw response count:", all.length); + if (all.length > 0) { + console.log("[fetchSessions] Sample session:", JSON.stringify(all[0], null, 2)); + } + + // Filter sesi yang aktif (belum atau sedang) DAN belum melewati tenggat + const aktif = all.filter((s) => { + // Hanya tampilkan status belum/sedang + if (s.status_pertemuan !== "belum" && s.status_pertemuan !== "sedang") { + return false; + } + // Cek apakah batas_presensi sudah lewat + if (s.batas_presensi) { + const batasStr = String(s.batas_presensi); + const batasDt = new Date(batasStr.endsWith("Z") ? batasStr : batasStr + "Z"); + if (!isNaN(batasDt.getTime()) && new Date() > batasDt) { + console.log(`[fetchSessions] Session ${s.id_pertemuan} expired (batas: ${batasStr})`); + return false; + } + } + return true; + }); + console.log("[fetchSessions] Filtered aktif count:", aktif.length); + + setSessions(aktif); + setIsDemo(false); + } catch (err) { + console.error("[fetchSessions] Error:", err); + setIsDemo(false); + setSessions([]); + } finally { + setLoadingSessions(false); + } + }; + + const fetchHistory = useCallback(async (isRefresh = false) => { + if (!token) return; + if (isRefresh) setHistoryRefreshing(true); + else setLoadingHistory(true); + + try { + // Ambil history berdasarkan id_mahasiswa dari user yang login + const idMhs = user?.id_mahasiswa; + const url = idMhs + ? `${API_BASE}/history-presensi/?id_mahasiswa=${idMhs}&limit=100` + : `${API_BASE}/history-presensi/?limit=100`; + + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as HistoryItem[]; + setHistoryList(Array.isArray(data) ? data : []); + } catch (err) { + console.warn("fetchHistory error:", err); + setHistoryList([]); + } finally { + setLoadingHistory(false); + setHistoryRefreshing(false); + } + }, [token, user?.id_mahasiswa]); + + + + const fetchGeolokasiData = async (id: number) => { + if (!token) return; + try { + const res = await fetch(`${API_BASE}/geolokasi/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = (await res.json()) as { + latitude: number; longitude: number; radius_meter: number; nama_lokasi: string; + }; + setGeolokasiData(data); + } catch { } + }; + + // ─── Cek apakah user sudah presensi untuk sesi ini ──────────────────────── + // Dual-strategy: (1) Cek via /pertemuan/{id}/presensi, (2) fallback via /history-presensi + const checkIfAlreadyAttended = async (session: Session): Promise<{ attended: boolean; status?: string; waktu?: string }> => { + if (!token || !user?.id_mahasiswa) { + console.log("[checkIfAlreadyAttended] No token or user, returning false"); + return { attended: false }; + } + + const mhsId = user.id_mahasiswa; + console.log(`[checkIfAlreadyAttended] Checking session ${session.id_pertemuan} for mahasiswa ${mhsId}`); + + // ── Strategi 1: Cek via endpoint detail presensi pertemuan ────────────── + try { + const detailRes = await fetch( + `${API_BASE}/pertemuan/${session.id_pertemuan}/presensi`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (detailRes.ok) { + const detailData = await detailRes.json(); + + // Endpoint ini mengembalikan mahasiswa yang ADA record di tabel Presensi. + // Record bisa berstatus "belum" (pre-populated) atau sudah scan (hadir/terlambat/dll). + // Hanya status selain "belum" yang berarti sudah benar-benar presensi. + if (detailData.mahasiswa_list && Array.isArray(detailData.mahasiswa_list)) { + const myRecord = detailData.mahasiswa_list.find( + (m: any) => Number(m.id_mahasiswa) === Number(mhsId) + ); + + if (myRecord) { + const status = myRecord.status_presensi; + console.log(`[checkIfAlreadyAttended] Strategi-1: Found record status=${status}`); + // Status "belum" = terdaftar di sesi tapi belum scan → belum presensi + // Status lain (hadir, terlambat, izin, sakit, alpha) = sudah presensi + if (status && status !== "belum") { + return { + attended: true, + status, + waktu: myRecord.waktu_presensi, + }; + } + console.log("[checkIfAlreadyAttended] Strategi-1: Status belum → belum presensi"); + return { attended: false }; + } + console.log("[checkIfAlreadyAttended] Strategi-1: User tidak ada di mahasiswa_list → belum presensi"); + return { attended: false }; + } + } else { + console.warn(`[checkIfAlreadyAttended] Strategi-1 API error: ${detailRes.status}, mencoba strategi-2`); + } + } catch (err) { + console.warn("[checkIfAlreadyAttended] Strategi-1 error:", err, "mencoba strategi-2"); + } + + // ── Strategi 2: Fallback via history-presensi ──────────────────────────── + try { + const histRes = await fetch( + `${API_BASE}/history-presensi/?id_mahasiswa=${mhsId}&limit=200`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (histRes.ok) { + const histData = await histRes.json(); + if (Array.isArray(histData)) { + const myRecord = histData.find( + (h: any) => Number(h.id_pertemuan) === Number(session.id_pertemuan) + ); + // Pastikan status bukan null, undefined, atau "belum" + if (myRecord && myRecord.status_presensi && myRecord.status_presensi !== "belum") { + console.log(`[checkIfAlreadyAttended] Strategi-2: Found history status=${myRecord.status_presensi}`); + return { + attended: true, + status: myRecord.status_presensi, + waktu: myRecord.waktu_presensi, + }; + } + } + console.log("[checkIfAlreadyAttended] Strategi-2: Tidak ada riwayat valid → belum presensi"); + return { attended: false }; + } else { + console.warn(`[checkIfAlreadyAttended] Strategi-2 API error: ${histRes.status}`); + } + } catch (err) { + console.warn("[checkIfAlreadyAttended] Strategi-2 error:", err); + } + + // Jika kedua strategi gagal (network error), asumsikan belum presensi + console.log("[checkIfAlreadyAttended] Semua strategi gagal, return false"); + return { attended: false }; + }; + + // ─── Handler untuk tap card presensi ───────────────────────────────────── + const handleSessionCardPress = async (session: Session) => { + setCheckingAttendance(session.id_pertemuan); + + try { + const result = await checkIfAlreadyAttended(session); + + if (result.attended) { + // User sudah presensi — tampilkan alert, JANGAN buka face recognition + const statusLabel = + result.status === "terlambat" ? "Terlambat" : + result.status === "izin" ? "Izin" : + result.status === "sakit" ? "Sakit" : + result.status === "alpha" ? "Alpha" : "Hadir"; + + const waktuText = result.waktu + ? `\nWaktu: ${new Date(result.waktu).toLocaleString("id-ID", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })}` + : ""; + + Alert.alert( + "✅ Presensi Sudah Tercatat", + `Anda sudah melakukan presensi untuk sesi ini.\n\nStatus: ${statusLabel}${waktuText}`, + [{ text: "OK" }] + ); + } else { + // User belum presensi — arahkan ke face recognition + setSelectedSession(session); + setScanStatus("idle"); + setCapturedImage(null); + setShowPreview(false); + setFaceScanResult(null); + setIsGalleryImage(false); + setGalleryBase64(null); + setView("scan"); + } + } catch (err) { + // Error tak terduga — arahkan ke face recognition sebagai fallback aman + console.error("[handleSessionCardPress] Unexpected error:", err); + setSelectedSession(session); + setScanStatus("idle"); + setCapturedImage(null); + setShowPreview(false); + setFaceScanResult(null); + setIsGalleryImage(false); + setGalleryBase64(null); + setView("scan"); + } finally { + setCheckingAttendance(null); + } + }; + + const loadUserLocation = async () => { + setLocationLoading(true); + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== "granted") { + Alert.alert( + "Izin Lokasi Ditolak", + "Aplikasi membutuhkan akses lokasi untuk presensi. Silahkan aktifkan di pengaturan." + ); + return; + } + + const loc = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, + }); + + const isMocked = !!(loc.mocked); + const accuracy = loc.coords.accuracy ?? 999; + const altitudeAccuracy = loc.coords.altitudeAccuracy ?? 999; + + const isFakeGPS = + isMocked || + accuracy > 200 || + accuracy < 1.5 || + altitudeAccuracy > 100; + + setIsMockLocation(isFakeGPS); + + if (!isFakeGPS) { + setUserLocation({ + latitude: loc.coords.latitude, + longitude: loc.coords.longitude, + }); + } + + if (locationWatchRef.current) { + locationWatchRef.current.remove(); + } + + locationWatchRef.current = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.High, + timeInterval: 3000, + distanceInterval: 2, + }, + (newLoc) => { + const isMocked = !!(newLoc.mocked); + const accuracy = newLoc.coords.accuracy ?? 999; + const altitudeAccuracy = newLoc.coords.altitudeAccuracy ?? 999; + + const isFakeGPS = + isMocked || + accuracy > 200 || + accuracy < 1.5 || + altitudeAccuracy > 100; + + setIsMockLocation(isFakeGPS); + + if (!isFakeGPS) { + setUserLocation({ + latitude: newLoc.coords.latitude, + longitude: newLoc.coords.longitude, + }); + } + } + ); + } catch (err) { + console.warn("loadUserLocation error:", err); + } finally { + setLocationLoading(false); + } + }; + + const handlePresensiSuccess = () => { + if (selectedSession) { + fetchSessions(); + } + setView("list"); + }; + + const DemoBanner = () => + isDemo ? ( + + + Menggunakan data demo + + ) : null; + + // ─── Tab Header ────────────────────────────────────────────────────────── + + const renderMainHeader = () => ( + + + + {/* Header */} + + + Presensi + + {mainTab === "presensi" ? "Sesi aktif & scan wajah" : "Riwayat kehadiran Anda"} + + + + + + {/* Tab Switcher */} + + setMainTab("presensi")} + activeOpacity={0.75} + > + + + Presensi Aktif + + + setMainTab("riwayat")} + activeOpacity={0.75} + > + + + Riwayat + + + + + + + {/* Content berdasarkan tab */} + {mainTab === "presensi" ? renderPresensiTab() : renderRiwayatTab()} + + ); + + // ─── Tab: Presensi Aktif ───────────────────────────────────────────────── + + const renderPresensiTab = () => { + if (loadingSessions) { + return ( + + + Memuat sesi... + + ); + } + + if (sessions.length === 0) { + return ( + + + Tidak ada sesi aktif + + Pastikan akun Anda sudah terdaftar di kelas yang memiliki jadwal pertemuan. + + + + Refresh Sekarang + + + + ); + } + + return ( + + } + > + {sessions.map((session) => { + const chip = getSessionChip(session.status_pertemuan, isDarkMode); + const isChecking = checkingAttendance === session.id_pertemuan; + + return ( + handleSessionCardPress(session)} + activeOpacity={0.75} + disabled={isChecking} + > + {isChecking && ( + + + Memeriksa status presensi... + + )} + + + + {chip.label} + + + + + + {session.nama_matakuliah} + + {session.kode_matakuliah} · Pertemuan ke-{session.pertemuan_ke} + + + + {formatDate(session.tanggal)} + + + + + + + + + {session.sudah_hadir} Hadir + + + + + {session.belum_presensi} Belum + + + + + {session.total_mahasiswa} Total + + + + + ); + })} + + ); + }; + + // ─── Tab: Riwayat Presensi ────────────────────────────────────────────── + + const renderRiwayatTab = () => { + if (loadingHistory) { + return ( + + + Memuat riwayat... + + ); + } + + if (historyList.length === 0) { + return ( + + + Belum ada riwayat + + Riwayat presensi Anda akan muncul di sini setelah melakukan presensi. + + fetchHistory()} + > + + Refresh + + + + ); + } + + // Hitung statistik ringkasan + const totalHadir = historyList.filter(h => h.status_presensi === "hadir").length; + const totalTerlambat = historyList.filter(h => h.status_presensi === "terlambat").length; + const totalIzin = historyList.filter(h => h.status_presensi === "izin").length; + const totalSakit = historyList.filter(h => h.status_presensi === "sakit").length; + const totalAlpha = historyList.filter(h => h.status_presensi === "alpha").length; + const totalAll = historyList.length; + const pctHadir = totalAll > 0 ? Math.round(((totalHadir + totalTerlambat) / totalAll) * 100) : 0; + + return ( + String(item.id_history)} + style={[styles.scrollView, { backgroundColor: colors.bg }]} + contentContainerStyle={[styles.scrollContent, { paddingTop: 0 }]} + showsVerticalScrollIndicator={false} + refreshControl={ + fetchHistory(true)} + tintColor={colors.primary} + colors={[colors.primary]} + /> + } + ListHeaderComponent={() => ( + <> + {/* Ringkasan statistik */} + + Ringkasan Kehadiran + + + + {totalHadir + totalTerlambat} + + Hadir + + + + + {totalTerlambat} + + Terlambat + + + + + {totalIzin} + + Izin + + + + + {totalSakit} + + Sakit + + + + + {totalAlpha} + + Alpha + + + {/* Progress bar kehadiran */} + + + Tingkat Kehadiran + + = 75 ? colors.success : pctHadir >= 50 ? colors.warning : colors.danger + }]}> + {pctHadir}% + + + + = 75 ? colors.success : pctHadir >= 50 ? colors.warning : colors.danger + }]} /> + + {totalAll} pertemuan tercatat + + Detail Riwayat + + )} + renderItem={({ item }) => { + const historyStatusMap = getHistoryStatusMap(isDarkMode); + const st = item.status_presensi + ? historyStatusMap[item.status_presensi] + : { color: colors.textMuted, bg: colors.bgInput, label: "-", icon: "help-circle" }; + + return ( + { + setSelectedHistory(item); + setShowHistoryDetail(true); + }} + activeOpacity={0.75} + > + {/* Status badge + waktu */} + + + + + {st.label} + + + + {item.tanggal ? formatDate(item.tanggal) : "-"} + + + + {/* Info matakuliah */} + + {item.nama_mk} + + + {item.kode_mk} · Pertemuan {item.pertemuan_ke ?? "-"} + + + {/* Info tambahan */} + + + + + {item.waktu_presensi ? formatDateTime(item.waktu_presensi) : "Belum tercatat"} + + + {item.jarak_meter !== null && item.jarak_meter !== undefined && ( + + + + {Math.round(item.jarak_meter)} m + + + )} + {item.confidence_score !== null && item.confidence_score !== undefined && ( + + + + {(item.confidence_score * 100).toFixed(0)}% + + + )} + + + ); + }} + /> + ); + }; + + // ─── Modal Detail Riwayat ──────────────────────────────────────────────── + + const renderHistoryDetailModal = () => { + if (!selectedHistory) return null; + const historyStatusMap = getHistoryStatusMap(isDarkMode); + const st = selectedHistory.status_presensi + ? historyStatusMap[selectedHistory.status_presensi] + : { color: colors.textMuted, bg: colors.bgInput, label: "-", icon: "help-circle" }; + + return ( + setShowHistoryDetail(false)} + > + + + {/* Header modal */} + + setShowHistoryDetail(false)} + > + + + Detail Riwayat + + + + + {/* Status utama */} + + + {st.label} + + {selectedHistory.tanggal ? formatDate(selectedHistory.tanggal) : "-"} + + + + {/* Info Matakuliah */} + + Informasi Pertemuan + + + + + + + + + + + + {/* Info Presensi */} + + Detail Presensi + + + + {selectedHistory.confidence_score !== null && selectedHistory.confidence_score !== undefined && ( + + )} + {selectedHistory.metode_deteksi && ( + + )} + {selectedHistory.jarak_meter !== null && selectedHistory.jarak_meter !== undefined && ( + + )} + {selectedHistory.latitude_presensi !== null && selectedHistory.latitude_presensi !== undefined && ( + + )} + + + + {/* Score teknis (opsional) */} + {(selectedHistory.lbph_score !== null || selectedHistory.cnn_score !== null) && ( + + Skor Verifikasi Wajah + + {selectedHistory.lbph_score !== null && selectedHistory.lbph_score !== undefined && ( + + )} + {selectedHistory.cnn_score !== null && selectedHistory.cnn_score !== undefined && ( + + )} + + + )} + + + + + + ); + }; + + // ─── View: scan ─────────────────────────────────────────────────────────── + + const renderScanView = () => { + if (!selectedSession || !token) return null; + + return ( + { + setScanStatus("idle"); + setCapturedImage(null); + setShowPreview(false); + setIsGalleryImage(false); + setGalleryBase64(null); + setView("list"); + }} + onPresensiSuccess={handlePresensiSuccess} + scanStatus={scanStatus} + setScanStatus={setScanStatus} + capturedImage={capturedImage} + setCapturedImage={setCapturedImage} + showPreview={showPreview} + setShowPreview={setShowPreview} + cameraRef={cameraRef} + facing={facing} + setFacing={setFacing} + isCapturing={isCapturing} + setIsCapturing={setIsCapturing} + faceScanResult={faceScanResult} + setFaceScanResult={setFaceScanResult} + isGalleryImage={isGalleryImage} + setIsGalleryImage={setIsGalleryImage} + galleryBase64={galleryBase64} + setGalleryBase64={setGalleryBase64} + /> + ); + }; + + if (view === "scan") return renderScanView(); + return ( + <> + {renderMainHeader()} + {renderHistoryDetailModal()} + + ); +} + +// ─── Helper Component ──────────────────────────────────────────────────────── + +function InfoRow({ + icon, label, value, valueColor, last, colors, +}: { + icon: string; + label: string; + value: string; + valueColor?: string; + last?: boolean; + colors: any; +}) { + return ( + + + + + {label} + + {value} + + + ); +} + +const infoRowStyles = StyleSheet.create({ + row: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 10, + gap: 10, + }, + rowBorder: { + borderBottomWidth: 1, + borderBottomColor: THEME.borderLight, + }, + iconWrap: { + width: 22, + alignItems: "center", + }, + label: { + flex: 1, + fontSize: 13, + color: THEME.textSecondary, + }, + value: { + fontSize: 13, + fontWeight: "500", + color: THEME.textPrimary, + maxWidth: "55%", + textAlign: "right", + }, +}); + +// ─── Styles ───────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: THEME.bg, + }, + + header: { + flexDirection: "row", + alignItems: "center", + paddingTop: Platform.OS === "android" ? (RNStatusBar.currentHeight ?? 40) : 55, + paddingHorizontal: 20, + paddingBottom: 14, + backgroundColor: THEME.bg, + }, + headerCenter: { + flex: 1, + marginLeft: 12, + }, + headerTitle: { + fontSize: 22, + fontWeight: "800", + color: THEME.textPrimary, + letterSpacing: 0.3, + }, + headerSubtitle: { + fontSize: 12, + color: THEME.primary, + fontWeight: "500", + marginTop: 2, + }, + backButton: { + width: 40, + height: 40, + borderRadius: 10, + backgroundColor: THEME.bgInput, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: THEME.border, + }, + iconButton: { + width: 40, + height: 40, + borderRadius: 10, + backgroundColor: THEME.bgInput, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: THEME.border, + }, + + // Tab Switcher + tabSwitcher: { + flexDirection: "row", + marginHorizontal: 20, + marginBottom: 12, + backgroundColor: THEME.bgInput, + borderRadius: 12, + padding: 4, + }, + tabItem: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + paddingVertical: 10, + borderRadius: 10, + }, + tabItemActive: { + backgroundColor: THEME.bgCard, + shadowColor: THEME.shadow, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 1, + shadowRadius: 4, + elevation: 2, + }, + tabLabel: { + fontSize: 13, + fontWeight: "600", + color: THEME.textMuted, + }, + tabLabelActive: { + color: THEME.primary, + }, + + demoBanner: { + flexDirection: "row", + alignItems: "center", + gap: 6, + marginHorizontal: 20, + marginBottom: 8, + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: THEME.warningBg, + borderRadius: 8, + borderWidth: 1, + borderColor: "#FDE68A", + }, + demoBannerText: { + fontSize: 12, + color: THEME.warning, + fontWeight: "500", + }, + + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 20, + paddingBottom: 32, + paddingTop: 4, + gap: 14, + }, + centerFill: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 12, + }, + loadingText: { + fontSize: 14, + color: THEME.textSecondary, + marginTop: 8, + }, + emptyTitle: { + fontSize: 16, + fontWeight: "600", + color: THEME.textSecondary, + textAlign: "center", + }, + emptySubtitle: { + fontSize: 13, + color: THEME.textMuted, + textAlign: "center", + paddingHorizontal: 32, + }, + + sessionCard: { + backgroundColor: THEME.bgCard, + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: THEME.border, + gap: 12, + shadowColor: THEME.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 1, + shadowRadius: 8, + elevation: 2, + }, + checkingOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(255, 255, 255, 0.9)", + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + zIndex: 10, + gap: 8, + }, + checkingText: { + fontSize: 12, + color: THEME.textSecondary, + fontWeight: "500", + }, + sessionChip: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 20, + gap: 6, + }, + sessionChipDot: { + width: 7, + height: 7, + borderRadius: 4, + }, + sessionChipText: { + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.3, + }, + sessionCardBody: { + flexDirection: "row", + alignItems: "center", + }, + sessionName: { + fontSize: 16, + fontWeight: "700", + color: THEME.textPrimary, + marginBottom: 4, + }, + sessionMeta: { + fontSize: 12, + color: THEME.textSecondary, + marginBottom: 4, + }, + sessionDateRow: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + sessionDate: { + fontSize: 12, + color: THEME.textMuted, + }, + statsRow: { + flexDirection: "row", + gap: 8, + }, + statsPill: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 20, + }, + statsPillText: { + fontSize: 12, + fontWeight: "600", + }, + + summaryBar: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginHorizontal: 20, + marginBottom: 12, + paddingHorizontal: 14, + paddingVertical: 10, + backgroundColor: THEME.bgCard, + borderRadius: 12, + borderWidth: 1, + borderColor: THEME.border, + }, + summaryText: { + fontSize: 13, + color: THEME.textSecondary, + }, + progressTrack: { + flex: 1, + height: 4, + backgroundColor: THEME.borderLight, + borderRadius: 2, + overflow: "hidden", + }, + progressFill: { + height: 4, + backgroundColor: THEME.success, + borderRadius: 2, + }, + summaryPct: { + fontSize: 12, + color: THEME.success, + fontWeight: "700", + minWidth: 34, + textAlign: "right", + }, + studentList: { + flex: 1, + }, + studentListContent: { + paddingHorizontal: 20, + paddingBottom: 96, + gap: 8, + }, + studentRow: { + flexDirection: "row", + alignItems: "center", + backgroundColor: THEME.bgCard, + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: THEME.borderLight, + gap: 12, + }, + avatarCircle: { + width: 42, + height: 42, + borderRadius: 21, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + }, + avatarInitials: { + fontSize: 14, + fontWeight: "700", + }, + studentInfo: { + flex: 1, + }, + studentNim: { + fontSize: 11, + color: THEME.textMuted, + marginBottom: 2, + }, + studentName: { + fontSize: 14, + fontWeight: "600", + color: THEME.textPrimary, + }, + attendanceBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 20, + borderWidth: 1, + }, + attendanceBadgeText: { + fontSize: 11, + fontWeight: "600", + }, + + bottomBar: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 20, + paddingVertical: 14, + paddingBottom: Platform.OS === "android" ? 14 : 28, + backgroundColor: THEME.bg, + borderTopWidth: 1, + borderTopColor: THEME.border, + gap: 12, + }, + scanButton: { + flex: 1, + borderRadius: 12, + overflow: "hidden", + }, + scanButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + paddingVertical: 14, + borderRadius: 12, + }, + scanButtonText: { + fontSize: 15, + fontWeight: "700", + color: "#fff", + }, + scanButtonDisabled: { + opacity: 0.6, + }, + + locationFreeBanner: { + marginHorizontal: 16, + marginBottom: 8, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: THEME.successBg, + borderRadius: 8, + borderWidth: 1, + borderColor: "#A7F3D0", + }, + locationFreeText: { + fontSize: 13, + color: THEME.success, + fontWeight: "500", + textAlign: "center", + }, + + mockGpsBanner: { + flexDirection: "row" as const, + alignItems: "center" as const, + gap: 10, + marginHorizontal: 16, + marginBottom: 8, + padding: 14, + backgroundColor: "#DC2626", + borderRadius: 12, + }, + mockGpsBannerTitle: { + fontSize: 14, + fontWeight: "700" as const, + color: "#fff", + }, + mockGpsBannerDesc: { + fontSize: 12, + color: "rgba(255,255,255,0.85)", + marginTop: 2, + }, + + // ─── History Styles ────────────────────────────────────────────────────── + + statsSummaryCard: { + backgroundColor: THEME.bgCard, + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: THEME.border, + marginBottom: 16, + shadowColor: THEME.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 1, + shadowRadius: 8, + elevation: 2, + }, + statsSummaryTitle: { + fontSize: 14, + fontWeight: "700", + color: THEME.textPrimary, + marginBottom: 12, + }, + statsSummaryRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 14, + }, + statsSummaryItem: { + flex: 1, + alignItems: "center", + }, + statsSummaryDivider: { + width: 1, + height: 30, + backgroundColor: THEME.border, + }, + statsSummaryNum: { + fontSize: 20, + fontWeight: "800", + }, + statsSummaryLabel: { + fontSize: 10, + color: THEME.textMuted, + marginTop: 2, + fontWeight: "500", + }, + statsSummaryProgressRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 6, + }, + statsSummaryProgressLabel: { + fontSize: 12, + color: THEME.textSecondary, + fontWeight: "500", + }, + statsSummaryProgressPct: { + fontSize: 13, + fontWeight: "700", + }, + statsSummaryTotal: { + fontSize: 11, + color: THEME.textMuted, + marginTop: 6, + textAlign: "right", + }, + historyListHeader: { + fontSize: 14, + fontWeight: "700", + color: THEME.textPrimary, + marginBottom: 8, + }, + historyCard: { + backgroundColor: THEME.bgCard, + borderRadius: 14, + padding: 14, + borderWidth: 1, + borderColor: THEME.border, + gap: 6, + shadowColor: THEME.shadow, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 1, + shadowRadius: 4, + elevation: 1, + }, + historyCardTop: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + historyStatusBadge: { + flexDirection: "row", + alignItems: "center", + gap: 5, + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 20, + }, + historyStatusText: { + fontSize: 12, + fontWeight: "700", + }, + historyDate: { + fontSize: 11, + color: THEME.textMuted, + fontWeight: "500", + }, + historyMkName: { + fontSize: 15, + fontWeight: "700", + color: THEME.textPrimary, + }, + historyMkCode: { + fontSize: 12, + color: THEME.textSecondary, + }, + historyCardFooter: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginTop: 4, + }, + historyMetaItem: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + historyMetaText: { + fontSize: 11, + color: THEME.textMuted, + }, + + // ─── Modal Styles ──────────────────────────────────────────────────────── + + modalContainer: { + flex: 1, + backgroundColor: THEME.bg, + }, + modalHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingTop: Platform.OS === "android" ? (RNStatusBar.currentHeight ?? 40) + 8 : 60, + paddingHorizontal: 20, + paddingBottom: 16, + backgroundColor: THEME.bg, + borderBottomWidth: 1, + borderBottomColor: THEME.border, + }, + modalCloseBtn: { + width: 40, + height: 40, + borderRadius: 10, + backgroundColor: THEME.bgInput, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: THEME.border, + }, + modalTitle: { + fontSize: 17, + fontWeight: "700", + color: THEME.textPrimary, + }, + modalScroll: { + flex: 1, + }, + modalStatusCard: { + alignItems: "center", + margin: 20, + padding: 24, + borderRadius: 16, + borderWidth: 1, + gap: 8, + }, + modalStatusLabel: { + fontSize: 22, + fontWeight: "800", + }, + modalStatusDate: { + fontSize: 13, + color: THEME.textSecondary, + fontWeight: "500", + }, + modalSection: { + marginHorizontal: 20, + marginBottom: 16, + }, + modalSectionTitle: { + fontSize: 12, + fontWeight: "700", + color: THEME.textMuted, + letterSpacing: 0.8, + textTransform: "uppercase", + marginBottom: 8, + }, + modalInfoCard: { + backgroundColor: THEME.bgCard, + borderRadius: 12, + paddingHorizontal: 14, + borderWidth: 1, + borderColor: THEME.border, + }, + sudahPresensiBar: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginHorizontal: 16, + marginBottom: 8, + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 10, + borderWidth: 1, + }, + sudahPresensiText: { + flex: 1, + fontSize: 13, + fontWeight: "600", + lineHeight: 18, + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/app/(tabs)/profile.tsx b/facerecognition_presensi/AndroidFolder/app/(tabs)/profile.tsx new file mode 100644 index 0000000..bd3d62a --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/(tabs)/profile.tsx @@ -0,0 +1,723 @@ +import { useState, useEffect } from "react"; +import { + StyleSheet, + View, + Text, + TouchableOpacity, + ScrollView, + Image, + Animated, + Alert, +} from "react-native"; +import { StatusBar } from "expo-status-bar"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTheme } from "@/contexts/ThemeContext"; +import { router } from "expo-router"; +import { API_BASE } from "@/constants/config"; + +// Mock data untuk attendance history +const attendanceData = [ + { + id: 1, + course: "Pemrograman Mobile", + date: "11 Feb 2025", + status: "present", + time: "08:00", + }, + { + id: 2, + course: "Basis Data Lanjut", + date: "10 Feb 2025", + status: "present", + time: "10:30", + }, + { + id: 3, + course: "Jaringan Komputer", + date: "09 Feb 2025", + status: "late", + time: "09:15", + }, + { + id: 4, + course: "Sistem Operasi", + date: "08 Feb 2025", + status: "absent", + time: "-", + }, + { + id: 5, + course: "Kecerdasan Buatan", + date: "07 Feb 2025", + status: "present", + time: "13:00", + }, + { + id: 6, + course: "Pemrograman Web", + date: "06 Feb 2025", + status: "present", + time: "07:45", + }, +]; + +// Statistik kehadiran +const stats = { + present: 85, + late: 10, + absent: 5, +}; + +// Sun Icon Component +const SunIcon = ({ color }: { color: string }) => ( + + ☀️ + +); + +// Moon Icon Component +const MoonIcon = ({ color }: { color: string }) => ( + + 🌙 + +); + +export default function ProfileScreen() { + const { user, logout } = useAuth(); + const { isDarkMode, toggleDarkMode, theme } = useTheme(); + const [activeFilter, setActiveFilter] = useState("all"); + const [sliderAnim] = useState(new Animated.Value(isDarkMode ? 1 : 0)); + + useEffect(() => { + Animated.timing(sliderAnim, { + toValue: isDarkMode ? 1 : 0, + duration: 350, + useNativeDriver: false, + }).start(); + }, [isDarkMode]); + + // Interpolate colors + const switchBgColor = sliderAnim.interpolate({ + inputRange: [0, 1], + outputRange: ["#73C0FC", "#183153"], + }); + + const sliderTranslate = sliderAnim.interpolate({ + inputRange: [0, 1], + outputRange: [2, 32], + }); + + const filteredData = + activeFilter === "all" + ? attendanceData + : attendanceData.filter((item) => item.status === activeFilter); + + const getStatusColor = (status: string) => { + switch (status) { + case "present": + return "#10B981"; + case "late": + return "#F59E0B"; + case "absent": + return "#EF4444"; + default: + return "#6B7280"; + } + }; + + const getStatusLabel = (status: string) => { + switch (status) { + case "present": + return "Hadir"; + case "late": + return "Terlambat"; + case "absent": + return "Sakit/Alpha"; + default: + return status; + } + }; + + const handleProfileImagePress = () => { + Alert.alert( + "Register Ulang Wajah", + "Apakah Anda ingin mengganti wajah dan meregister ulang?", + [ + { + text: "Batal", + style: "cancel", + }, + { + text: "Ya", + onPress: () => router.push("/register-foto"), + }, + ] + ); + }; + + return ( + + + + + {/* Header Background */} + + {/* Decorative Circles */} + + + + + Profil Mahasiswa + + Face Recognition System + + + + + {/* Profile Card */} + + + + + + + + + + + + + + + + {user?.nama_mahasiswa ?? user?.name ?? "Mahasiswa"} + + + NIM: {user?.nim ?? "-"} + + + + + Mahasiswa Aktif + + + + + + {/* Student Details Grid */} + + + + Kelas + + + {user?.nama_kelas ?? "-"} + + + + + Email + + + {user?.email ?? "-"} + + + + + + {/* Dark Mode Switch */} + + + + + + + + Mode + + + + {/* Custom Toggle Switch */} + + + {/* Sun icon */} + + ☀️ + + {/* Moon icon */} + + 🌙 + + + + + + + + {/* Settings & System Menu */} + {/* + + + Pengaturan & Bantuan + + + + + + + + + + + + + + Hubungi Admin + + + + + + + + + + + + + + + + Tentang Aplikasi + + + + + + + + +*/} + + {/* Logout Button */} + { + await logout(); + router.replace("/login"); + }} + > + + + + Keluar dari Akun + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#F8FAFC", + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + header: { + backgroundColor: "#2563EB", + height: 220, + paddingTop: 60, + paddingHorizontal: 24, + position: "relative", + overflow: "hidden", + }, + circle1: { + width: 180, + height: 180, + borderRadius: 90, + backgroundColor: "#3B82F6", + opacity: 0.2, + position: "absolute", + top: -40, + right: -40, + }, + circle2: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: "#60A5FA", + opacity: 0.15, + position: "absolute", + bottom: 20, + left: -30, + }, + headerTextContainer: { + zIndex: 10, + }, + headerTitle: { + fontSize: 24, + fontWeight: "800", + color: "#FFFFFF", + letterSpacing: 0.5, + }, + headerSubtitle: { + fontSize: 14, + color: "#93C5FD", + marginTop: 4, + fontWeight: "500", + }, + profileCard: { + backgroundColor: "#FFFFFF", + marginHorizontal: 20, + borderRadius: 24, + padding: 24, + alignItems: "center", + marginTop: -80, + shadowColor: "#1E3A8A", + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.06, + shadowRadius: 15, + elevation: 6, + }, + profileImageContainer: { + position: "relative", + marginBottom: 16, + }, + outerRing: { + width: 114, + height: 114, + borderRadius: 57, + borderWidth: 2, + borderColor: "#DBEAFE", + justifyContent: "center", + alignItems: "center", + }, + innerRing: { + width: 104, + height: 104, + borderRadius: 52, + backgroundColor: "#FFFFFF", + padding: 3, + }, + profileImage: { + width: "100%", + height: "100%", + borderRadius: 49, + }, + statusBadge: { + position: "absolute", + bottom: 4, + right: 4, + backgroundColor: "#FFFFFF", + borderRadius: 14, + padding: 3, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + statusDot: { + width: 14, + height: 14, + borderRadius: 7, + backgroundColor: "#10B981", + }, + studentName: { + fontSize: 20, + fontWeight: "800", + color: "#0F172A", + marginBottom: 4, + textAlign: "center", + }, + studentNim: { + fontSize: 14, + color: "#64748B", + fontWeight: "500", + marginBottom: 12, + }, + statusPill: { + backgroundColor: "#DCFCE7", + paddingHorizontal: 14, + paddingVertical: 6, + borderRadius: 100, + marginBottom: 20, + }, + statusPillText: { + fontSize: 12, + fontWeight: "700", + color: "#15803D", + }, + divider: { + height: 1, + backgroundColor: "#F1F5F9", + width: "100%", + marginBottom: 16, + }, + detailsGrid: { + width: "100%", + gap: 12, + }, + detailsRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + detailsLabel: { + fontSize: 13, + color: "#64748B", + fontWeight: "500", + }, + detailsValue: { + fontSize: 14, + color: "#0F172A", + fontWeight: "600", + }, + // Dark Mode Switch Card + switchCard: { + backgroundColor: "#FFFFFF", + marginHorizontal: 20, + marginTop: 16, + borderRadius: 20, + padding: 16, + shadowColor: "#1E3A8A", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.04, + shadowRadius: 10, + elevation: 3, + }, + switchRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + switchLeft: { + flexDirection: "row", + alignItems: "center", + }, + switchLabel: { + fontSize: 14, + fontWeight: "600", + color: "#0F172A", + }, + switchTouchable: { + padding: 4, + }, + switchTrack: { + width: 64, + height: 34, + borderRadius: 30, + justifyContent: "center", + position: "relative", + }, + switchThumb: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: "#e8e8e8", + position: "absolute", + bottom: 2, + zIndex: 2, + }, + switchIcon: { + position: "absolute", + top: 5, + zIndex: 1, + }, + // Settings + settingsSection: { + marginTop: 24, + paddingHorizontal: 20, + }, + settingsSectionTitle: { + fontSize: 16, + fontWeight: "700", + color: "#0F172A", + marginBottom: 12, + }, + settingsCard: { + backgroundColor: "#FFFFFF", + borderRadius: 20, + paddingHorizontal: 16, + shadowColor: "#1E3A8A", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.04, + shadowRadius: 10, + elevation: 3, + }, + settingsItem: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 14, + }, + settingsItemLeft: { + flexDirection: "row", + alignItems: "center", + }, + settingsIconWrapper: { + width: 36, + height: 36, + borderRadius: 10, + justifyContent: "center", + alignItems: "center", + marginRight: 12, + }, + settingsItemText: { + fontSize: 14, + fontWeight: "600", + color: "#0F172A", + }, + settingsDivider: { + height: 1, + backgroundColor: "#F1F5F9", + }, + // Logout + logoutButton: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#FEF2F2", + marginHorizontal: 20, + marginTop: 24, + padding: 16, + borderRadius: 18, + borderWidth: 1, + borderColor: "#FEE2E2", + }, + logoutIconContainer: { + width: 38, + height: 38, + borderRadius: 10, + backgroundColor: "#FEE2E2", + justifyContent: "center", + alignItems: "center", + marginRight: 12, + }, + logoutText: { + flex: 1, + fontSize: 15, + fontWeight: "700", + color: "#EF4444", + }, + bottomPadding: { + height: 40, + }, +}); \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/app/(tabs)/register-foto.tsx b/facerecognition_presensi/AndroidFolder/app/(tabs)/register-foto.tsx new file mode 100644 index 0000000..13df74c --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/(tabs)/register-foto.tsx @@ -0,0 +1,3481 @@ +// import React, { useState, useRef, useEffect, useCallback } from "react"; +// import { +// View, +// Text, +// StyleSheet, +// TouchableOpacity, +// Alert, +// Dimensions, +// Platform, +// StatusBar, +// Animated, +// Image, +// ScrollView, +// ActivityIndicator, +// Vibration, +// Modal, +// PanResponder, +// GestureResponderEvent, +// PanResponderGestureState, +// } from "react-native"; +// import { CameraView, CameraType, useCameraPermissions } from "expo-camera"; +// import { Ionicons } from "@expo/vector-icons"; +// import { LinearGradient } from "expo-linear-gradient"; +// import * as ImageManipulator from "expo-image-manipulator"; +// import * as ImagePicker from "expo-image-picker"; +// import { useAuth } from "@/contexts/AuthContext"; +// import { API_BASE } from "@/constants/config"; + +// // ─── Pose Types ───────────────────────────────────────────────────────────── + +// type PoseType = 'center' | 'right' | 'left'; + +// interface PoseConfig { +// id: PoseType; +// label: string; +// icon: string; +// description: string; +// color: string; +// } + +// const POSES: PoseConfig[] = [ +// { id: 'center', label: 'Hadap Depan', icon: '👤', description: 'Lihat kamera secara langsung', color: '#2563EB' }, +// { id: 'right', label: 'Hadap Kanan', icon: '👉', description: 'Putar kepala ke kanan ~30°', color: '#8B5CF6' }, +// { id: 'left', label: 'Hadap Kiri', icon: '👈', description: 'Putar kepala ke kiri ~30°', color: '#EC4899' }, +// ]; + +// const CAPTURES_PER_POSE = 2; // burst: 2 foto per klik + +// // ─── Constants ────────────────────────────────────────────────────────────── + +// const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); +// const CAMERA_SIZE = SCREEN_WIDTH * 0.85; +// const OVAL_WIDTH = CAMERA_SIZE * 0.55; +// const OVAL_HEIGHT = CAMERA_SIZE * 0.75; +// const OVAL_TOP = (CAMERA_SIZE - OVAL_HEIGHT) / 2; +// const OVAL_LEFT = (CAMERA_SIZE - OVAL_WIDTH) / 2; + +// // Crop Editor constants +// const EDITOR_SIZE = SCREEN_WIDTH * 0.9; +// const EDITOR_OVAL_WIDTH = EDITOR_SIZE * 0.6; +// const EDITOR_OVAL_HEIGHT = EDITOR_SIZE * 0.8; + +// // ─── Theme Colors ─────────────────────────────────────────────────────────── + +// const THEME = { +// bg: "#FFFFFF", +// bgSecondary: "#F8FAFC", +// bgCard: "#FFFFFF", +// bgInput: "#F1F5F9", +// border: "#E2E8F0", +// borderLight: "#F1F5F9", +// textPrimary: "#1E293B", +// textSecondary: "#64748B", +// textMuted: "#94A3B8", +// primary: "#2563EB", +// primaryLight: "#3B82F6", +// primaryDark: "#1D4ED8", +// primaryBg: "#EFF6FF", +// primaryBgDark: "#DBEAFE", +// success: "#10B981", +// successBg: "#ECFDF5", +// warning: "#F59E0B", +// warningBg: "#FFFBEB", +// danger: "#EF4444", +// dangerBg: "#FEF2F2", +// shadow: "rgba(0, 0, 0, 0.06)", +// shadowDark: "rgba(0, 0, 0, 0.08)", +// }; + +// // ─── Oval Mask Overlay ────────────────────────────────────────────────────── + +// function OvalMaskOverlay() { +// return ; +// } + +// // ─── Crop Editor Modal Component ─────────────────────────────────────────── +// // Approach: fixed oval mask, user drags/zooms the IMAGE behind it. +// // This ensures what-you-see-is-what-you-get when confirming the crop. + +// interface CropEditorProps { +// visible: boolean; +// imageUri: string; +// onConfirm: (croppedUri: string) => void; +// onCancel: () => void; +// } + +// function CropEditor({ visible, imageUri, onConfirm, onCancel }: CropEditorProps) { +// const [isProcessing, setIsProcessing] = useState(false); + +// // Original image pixel dimensions +// const [originalWidth, setOriginalWidth] = useState(0); +// const [originalHeight, setOriginalHeight] = useState(0); + +// // Display dimensions (fit image into EDITOR_SIZE, covering the oval) +// const [displayWidth, setDisplayWidth] = useState(EDITOR_SIZE); +// const [displayHeight, setDisplayHeight] = useState(EDITOR_SIZE); + +// // Image offset (how much the image is dragged) +// const [imageOffsetX, setImageOffsetX] = useState(0); +// const [imageOffsetY, setImageOffsetY] = useState(0); +// const [scale, setScale] = useState(1); + +// // Refs to track current values in gesture handlers (avoids stale closures) +// const offsetXRef = useRef(0); +// const offsetYRef = useRef(0); +// const scaleRef = useRef(1); +// const displayWRef = useRef(EDITOR_SIZE); +// const displayHRef = useRef(EDITOR_SIZE); +// const gestureStartOffset = useRef({ x: 0, y: 0 }); + +// // Load image dimensions +// useEffect(() => { +// if (imageUri) { +// Image.getSize( +// imageUri, +// (w, h) => { +// setOriginalWidth(w); +// setOriginalHeight(h); + +// // Display size: scale to COVER the oval area (so the image is always at least as large as the oval) +// const scaleX = EDITOR_SIZE / w; +// const scaleY = EDITOR_SIZE / h; +// const fitScale = Math.max(scaleX, scaleY); + +// const dw = w * fitScale; +// const dh = h * fitScale; +// setDisplayWidth(dw); +// setDisplayHeight(dh); +// displayWRef.current = dw; +// displayHRef.current = dh; +// }, +// (error) => console.warn('Failed to get image size:', error) +// ); +// } +// }, [imageUri]); + +// // Clamp helper: ensures the oval window always stays within the scaled image +// const clampOffset = (ox: number, oy: number, currentScale: number) => { +// const scaledW = displayWRef.current * currentScale; +// const scaledH = displayHRef.current * currentScale; + +// // How far the image can move from center before the oval sees outside the image +// const maxOffsetX = Math.max(0, (scaledW - EDITOR_OVAL_WIDTH) / 2); +// const maxOffsetY = Math.max(0, (scaledH - EDITOR_OVAL_HEIGHT) / 2); + +// return { +// x: Math.max(-maxOffsetX, Math.min(maxOffsetX, ox)), +// y: Math.max(-maxOffsetY, Math.min(maxOffsetY, oy)), +// }; +// }; + +// // PanResponder — moves the image behind the fixed oval +// const panResponder = useRef( +// PanResponder.create({ +// onStartShouldSetPanResponder: () => true, +// onMoveShouldSetPanResponder: (_, gs) => +// Math.abs(gs.dx) > 2 || Math.abs(gs.dy) > 2, +// onPanResponderGrant: () => { +// gestureStartOffset.current = { +// x: offsetXRef.current, +// y: offsetYRef.current, +// }; +// }, +// onPanResponderMove: (_, gestureState) => { +// const newX = gestureStartOffset.current.x + gestureState.dx; +// const newY = gestureStartOffset.current.y + gestureState.dy; +// const clamped = clampOffset(newX, newY, scaleRef.current); + +// offsetXRef.current = clamped.x; +// offsetYRef.current = clamped.y; +// setImageOffsetX(clamped.x); +// setImageOffsetY(clamped.y); +// }, +// onPanResponderRelease: () => {}, +// }) +// ).current; + +// const applyZoom = (newScale: number) => { +// const clamped = clampOffset(offsetXRef.current, offsetYRef.current, newScale); +// offsetXRef.current = clamped.x; +// offsetYRef.current = clamped.y; +// scaleRef.current = newScale; +// setScale(newScale); +// setImageOffsetX(clamped.x); +// setImageOffsetY(clamped.y); +// }; + +// const zoomIn = () => applyZoom(Math.min(3, scale + 0.2)); +// const zoomOut = () => applyZoom(Math.max(0.5, scale - 0.2)); + +// const resetPosition = () => { +// offsetXRef.current = 0; +// offsetYRef.current = 0; +// scaleRef.current = 1; +// setImageOffsetX(0); +// setImageOffsetY(0); +// setScale(1); +// }; + +// const handleConfirm = async () => { +// if (originalWidth === 0 || originalHeight === 0) return; +// setIsProcessing(true); +// try { +// // The image is displayed at (displayWidth * scale) x (displayHeight * scale) +// // centered in the EDITOR_SIZE container, then offset by (imageOffsetX, imageOffsetY). +// // +// // The oval is fixed at the center of the container. +// // We need to find which region of the ORIGINAL image is visible through the oval. + +// const scaledW = displayWidth * scale; +// const scaledH = displayHeight * scale; + +// // Ratio from scaled-display pixels to original pixels +// const ratioX = originalWidth / scaledW; +// const ratioY = originalHeight / scaledH; + +// // The oval's top-left in container coordinates +// const ovalLeft = (EDITOR_SIZE - EDITOR_OVAL_WIDTH) / 2; +// const ovalTop = (EDITOR_SIZE - EDITOR_OVAL_HEIGHT) / 2; + +// // The image's top-left in container coordinates +// // (image is centered, then shifted by offset) +// const imgLeft = (EDITOR_SIZE - scaledW) / 2 + imageOffsetX; +// const imgTop = (EDITOR_SIZE - scaledH) / 2 + imageOffsetY; + +// // The oval's position relative to the image's top-left (in scaled-display coords) +// const ovalInImgX = ovalLeft - imgLeft; +// const ovalInImgY = ovalTop - imgTop; + +// // Convert to original image pixel coordinates +// let cropOriginX = Math.round(ovalInImgX * ratioX); +// let cropOriginY = Math.round(ovalInImgY * ratioY); +// let cropWidth = Math.round(EDITOR_OVAL_WIDTH * ratioX); +// let cropHeight = Math.round(EDITOR_OVAL_HEIGHT * ratioY); + +// // Clamp to original image bounds +// cropOriginX = Math.max(0, Math.min(cropOriginX, originalWidth - 1)); +// cropOriginY = Math.max(0, Math.min(cropOriginY, originalHeight - 1)); +// cropWidth = Math.max(10, Math.min(cropWidth, originalWidth - cropOriginX)); +// cropHeight = Math.max(10, Math.min(cropHeight, originalHeight - cropOriginY)); + +// const cropRegion = { +// originX: cropOriginX, +// originY: cropOriginY, +// width: cropWidth, +// height: cropHeight, +// }; + +// const result = await ImageManipulator.manipulateAsync( +// imageUri, +// [ +// { crop: cropRegion }, +// { resize: { width: 800 } }, +// ], +// { compress: 0.9, format: ImageManipulator.SaveFormat.JPEG } +// ); + +// onConfirm(result.uri); +// } catch (error) { +// console.error('Crop error:', error); +// Alert.alert('Error', 'Gagal memproses foto. Silakan coba lagi.'); +// } finally { +// setIsProcessing(false); +// } +// }; + +// // Reset state when modal opens +// useEffect(() => { +// if (visible) { +// offsetXRef.current = 0; +// offsetYRef.current = 0; +// scaleRef.current = 1; +// setImageOffsetX(0); +// setImageOffsetY(0); +// setScale(1); +// } +// }, [visible, imageUri]); + +// return ( +// +// +// +// +// +// +// Atur Posisi Wajah +// +// {isProcessing ? ( +// +// ) : ( +// +// )} +// +// + +// +// +// Geser foto untuk menyesuaikan posisi wajah di dalam oval +// +// + +// +// +// {/* Image — draggable & zoomable behind the fixed oval */} +// + +// {/* Dark overlay with transparent oval cutout */} +// +// {/* Top dark band */} +// +// {/* Middle row: left dark | oval hole | right dark */} +// +// +// +// +// +// +// +// {/* Bottom dark band */} +// +// +// +// + +// +// +// +// +// +// {Math.round(scale * 100)}% +// +// +// +// + +// +// +// Reset +// +// +// +// +// ); +// } + +// const OVAL_TOP_OFFSET = (EDITOR_SIZE - EDITOR_OVAL_HEIGHT) / 2; +// const OVAL_LEFT_OFFSET = (EDITOR_SIZE - EDITOR_OVAL_WIDTH) / 2; + +// const cropStyles = StyleSheet.create({ +// modalContainer: { +// flex: 1, +// backgroundColor: '#000', +// }, +// header: { +// flexDirection: 'row', +// alignItems: 'center', +// justifyContent: 'space-between', +// paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) + 10 : 50, +// paddingHorizontal: 16, +// paddingBottom: 12, +// backgroundColor: 'rgba(0,0,0,0.9)', +// }, +// headerButton: { +// width: 44, +// height: 44, +// alignItems: 'center', +// justifyContent: 'center', +// }, +// headerTitle: { +// fontSize: 18, +// fontWeight: '700', +// color: '#fff', +// }, +// instructionContainer: { +// paddingHorizontal: 20, +// paddingVertical: 12, +// backgroundColor: 'rgba(0,0,0,0.7)', +// }, +// instructionText: { +// fontSize: 14, +// color: 'rgba(255,255,255,0.8)', +// textAlign: 'center', +// }, +// editorContainer: { +// flex: 1, +// alignItems: 'center', +// justifyContent: 'center', +// padding: 10, +// }, +// imageWrapper: { +// width: EDITOR_SIZE, +// height: EDITOR_SIZE, +// position: 'relative', +// alignItems: 'center', +// justifyContent: 'center', +// overflow: 'hidden', +// borderRadius: 12, +// }, +// fullImage: { +// position: 'absolute', +// }, +// // Dark overlay pieces around the oval cutout +// overlayContainer: { +// ...StyleSheet.absoluteFillObject, +// }, +// darkBandHorizontal: { +// width: '100%', +// height: OVAL_TOP_OFFSET, +// backgroundColor: 'rgba(0,0,0,0.55)', +// }, +// middleRow: { +// flexDirection: 'row', +// height: EDITOR_OVAL_HEIGHT, +// }, +// darkBandVertical: { +// width: OVAL_LEFT_OFFSET, +// height: '100%', +// backgroundColor: 'rgba(0,0,0,0.55)', +// }, +// ovalHole: { +// width: EDITOR_OVAL_WIDTH, +// height: EDITOR_OVAL_HEIGHT, +// borderRadius: EDITOR_OVAL_WIDTH / 2, +// overflow: 'hidden', +// // transparent — lets the image show through +// }, +// ovalBorder: { +// ...StyleSheet.absoluteFillObject, +// borderRadius: EDITOR_OVAL_WIDTH / 2, +// borderWidth: 3, +// borderColor: '#fff', +// }, +// controlsContainer: { +// paddingHorizontal: 20, +// paddingVertical: 20, +// backgroundColor: 'rgba(0,0,0,0.9)', +// borderTopWidth: 1, +// borderTopColor: 'rgba(255,255,255,0.1)', +// }, +// zoomControls: { +// flexDirection: 'row', +// alignItems: 'center', +// justifyContent: 'center', +// gap: 24, +// marginBottom: 16, +// }, +// zoomButton: { +// width: 50, +// height: 50, +// borderRadius: 25, +// backgroundColor: 'rgba(255,255,255,0.15)', +// alignItems: 'center', +// justifyContent: 'center', +// }, +// zoomLabel: { +// fontSize: 16, +// fontWeight: '600', +// color: '#fff', +// minWidth: 60, +// textAlign: 'center', +// }, +// resetButton: { +// flexDirection: 'row', +// alignItems: 'center', +// justifyContent: 'center', +// gap: 8, +// paddingVertical: 12, +// paddingHorizontal: 24, +// backgroundColor: 'rgba(255,255,255,0.1)', +// borderRadius: 12, +// }, +// resetText: { +// fontSize: 14, +// color: THEME.textSecondary, +// fontWeight: '500', +// }, +// }); + +// // ─── Main Component ──────────────────────────────────────────────────────────── + +// export default function RegisterFotoScreen() { +// const { user, token } = useAuth(); + +// // ── State ───────────────────────────────────────────────────────────────── +// const [permission, requestPermission] = useCameraPermissions(); +// const [facing] = useState("front"); +// const [isCapturing, setIsCapturing] = useState(false); +// const [isSubmitting, setIsSubmitting] = useState(false); +// const [capturedImages, setCapturedImages] = useState([]); +// const [showPreview, setShowPreview] = useState(false); +// const [captureAnim] = useState(new Animated.Value(1)); +// const [flashAnim] = useState(new Animated.Value(0)); +// const [isAlreadyRegistered, setIsAlreadyRegistered] = useState(false); +// const [isCheckingStatus, setIsCheckingStatus] = useState(true); +// const cameraRef = useRef(null); + +// // ── Crop Editor State ───────────────────────────────────────────────────── +// const [showCropEditor, setShowCropEditor] = useState(false); +// const [selectedImageUri, setSelectedImageUri] = useState(''); + +// // ── Guided Pose State ───────────────────────────────────────────────────── +// const [currentPoseIndex, setCurrentPoseIndex] = useState(0); + +// const currentPose = POSES[currentPoseIndex]; +// const totalRequired = POSES.length * CAPTURES_PER_POSE; +// const allCaptured = capturedImages.length >= totalRequired; + +// // ── Reset ───────────────────────────────────────────────────────────────── +// const resetCapture = useCallback(() => { +// setCapturedImages([]); +// setCurrentPoseIndex(0); +// setShowPreview(false); +// }, []); + +// // ── Check registration status ───────────────────────────────────────────── +// useEffect(() => { +// const checkRegistrationStatus = async () => { +// if (!token || !user?.nim) { +// setIsCheckingStatus(false); +// return; +// } + +// try { +// const response = await fetch(`${API_BASE}/arcface/status/${user.nim}`, { +// method: "GET", +// headers: { "Authorization": `Bearer ${token}` }, +// }); + +// if (response.ok || response.status === 200) { +// const result = await response.json(); +// setIsAlreadyRegistered(result.is_registered === true); +// } else { +// setIsAlreadyRegistered(false); +// } +// } catch (error) { +// console.warn("Gagal cek status registrasi:", error); +// setIsAlreadyRegistered(false); +// } finally { +// setIsCheckingStatus(false); +// } +// }; + +// checkRegistrationStatus(); +// }, [token, user?.nim]); + +// // ── Crop to oval region ─────────────────────────────────────────────────── +// const cropToOvalRegion = async ( +// photoUri: string, +// photoWidth: number, +// photoHeight: number, +// isFront: boolean +// ): Promise => { +// try { +// const containerSize = CAMERA_SIZE; +// const scale = Math.max(containerSize / photoWidth, containerSize / photoHeight); +// const displayedWidth = photoWidth * scale; +// const displayedHeight = photoHeight * scale; +// const offsetX = (displayedWidth - containerSize) / 2; +// const offsetY = (displayedHeight - containerSize) / 2; + +// let originX = (OVAL_LEFT + offsetX) / scale; +// let originY = (OVAL_TOP + offsetY) / scale; +// let width = OVAL_WIDTH / scale; +// let height = OVAL_HEIGHT / scale; + +// originX = Math.max(0, Math.min(originX, photoWidth - 1)); +// originY = Math.max(0, Math.min(originY, photoHeight - 1)); +// width = Math.max(1, Math.min(width, photoWidth - originX)); +// height = Math.max(1, Math.min(height, photoHeight - originY)); + +// const cropRegion = { +// originX: Math.round(originX), +// originY: Math.round(originY), +// width: Math.round(width), +// height: Math.round(height), +// }; + +// const actions: ImageManipulator.Action[] = []; +// if (isFront) { +// actions.push({ flip: ImageManipulator.FlipType.Horizontal }); +// } +// actions.push({ crop: cropRegion }); +// actions.push({ resize: { width: 800 } }); + +// const manipResult = await ImageManipulator.manipulateAsync( +// photoUri, +// actions, +// { compress: 0.9, format: ImageManipulator.SaveFormat.JPEG } +// ); + +// return manipResult.uri; +// } catch (error) { +// console.warn("Gagal melakukan crop oval:", error); +// return photoUri; +// } +// }; + +// // ── Burst capture: 1 klik = 2 foto ─────────────────────────────────────── +// const handleBurstCapture = async () => { +// if (!cameraRef.current || isCapturing || allCaptured) return; + +// // Capture animation +// Animated.sequence([ +// Animated.timing(captureAnim, { toValue: 0.92, duration: 80, useNativeDriver: true }), +// Animated.spring(captureAnim, { toValue: 1, friction: 4, useNativeDriver: true }), +// ]).start(); + +// setIsCapturing(true); + +// try { +// const newPhotos: string[] = []; + +// for (let i = 0; i < CAPTURES_PER_POSE; i++) { +// // Flash effect +// Animated.sequence([ +// Animated.timing(flashAnim, { toValue: 1, duration: 60, useNativeDriver: true }), +// Animated.timing(flashAnim, { toValue: 0, duration: 150, useNativeDriver: true }), +// ]).start(); + +// const photo = await cameraRef.current.takePictureAsync({ +// quality: 0.85, +// shutterSound: false, +// }); + +// if (photo?.uri) { +// const croppedUri = photo.width && photo.height +// ? await cropToOvalRegion(photo.uri, photo.width, photo.height, facing === "front") +// : photo.uri; + +// newPhotos.push(croppedUri); +// Vibration.vibrate(30); +// } + +// // Jeda singkat antara 2 foto burst +// if (i < CAPTURES_PER_POSE - 1) { +// await new Promise(resolve => setTimeout(resolve, 400)); +// } +// } + +// // Update state +// setCapturedImages(prev => [...prev, ...newPhotos]); + +// // Pindah ke pose berikutnya atau selesai +// if (currentPoseIndex < POSES.length - 1) { +// setCurrentPoseIndex(prev => prev + 1); +// } +// // Jika ini pose terakhir, allCaptured akan jadi true di render berikutnya + +// } catch (error) { +// Alert.alert("Error", "Gagal mengambil foto. Silakan coba lagi."); +// } finally { +// setIsCapturing(false); +// } +// }; + +// const handleRetake = () => { +// resetCapture(); +// }; + +// // ── Handle Delete ───────────────────────────────────────────────────────── +// const handleDelete = async () => { +// if (!user?.nim || !token) { +// Alert.alert("Error", "Silakan login terlebih dahulu."); +// return; +// } + +// Alert.alert( +// "Hapus Registrasi?", +// `Apakah Anda yakin ingin menghapus data wajah untuk NIM ${user.nim}? Anda perlu registrasi ulang di kemudian hari.`, +// [ +// { text: "Batal", style: "cancel" }, +// { +// text: "Hapus", +// style: "destructive", +// onPress: async () => { +// try { +// const response = await fetch(`${API_BASE}/arcface/delete/${user.nim}`, { +// method: "DELETE", +// headers: { "Authorization": `Bearer ${token}` }, +// }); +// const result = await response.json(); +// if (result.success) { +// setIsAlreadyRegistered(false); +// Alert.alert("Berhasil", "Data wajah berhasil dihapus."); +// } else { +// Alert.alert("Gagal", result.message || "Gagal menghapus data wajah."); +// } +// } catch (error) { +// Alert.alert("Error", "Terjadi kesalahan koneksi."); +// } +// }, +// }, +// ] +// ); +// }; + +// // ── Handle Confirm / Submit ─────────────────────────────────────────────── +// const handleConfirm = async () => { +// if (capturedImages.length === 0) { +// Alert.alert("Error", "Tidak ada foto yang akan dikirim."); +// return; +// } + +// if (!user?.nim) { +// Alert.alert("Error", "Silakan login terlebih dahulu untuk melakukan registrasi wajah."); +// return; +// } + +// if (isAlreadyRegistered) { +// Alert.alert( +// "Perbarui Foto?", +// `Wajah Anda sudah terdaftar. Foto lama akan ditimpa dengan foto baru.\n\nApakah ingin melanjutkan?`, +// [ +// { text: "Batal", style: "cancel" }, +// { text: "Lanjutkan", onPress: () => performEnrollment(true) }, +// ] +// ); +// } else { +// performEnrollment(false); +// } +// }; + +// const performEnrollment = async (forceUpdate: boolean) => { +// setIsSubmitting(true); + +// try { +// const photosBase64: string[] = []; +// for (const photoUri of capturedImages) { +// const base64Response = await fetch(photoUri); +// const blob = await base64Response.blob(); +// const reader = new FileReader(); +// const base64 = await new Promise((resolve, reject) => { +// reader.onloadend = () => resolve(reader.result as string); +// reader.onerror = () => reject(new Error("Failed to read image")); +// reader.readAsDataURL(blob); +// }); +// photosBase64.push(base64); +// } + +// const response = await fetch(`${API_BASE}/arcface/enroll`, { +// method: "POST", +// headers: { +// "Content-Type": "application/json", +// "Authorization": `Bearer ${token}`, +// }, +// body: JSON.stringify({ +// nim: user!.nim, +// foto_base64: photosBase64, +// force_update: forceUpdate, +// }), +// }); + +// const result = await response.json(); + +// if (result.success) { +// setIsAlreadyRegistered(true); +// Alert.alert( +// forceUpdate ? "Berhasil Diperbarui! 🎉" : "Berhasil! 🎉", +// forceUpdate +// ? `Wajah ${user!.nama_mahasiswa || user!.name} (${user!.nim}) berhasil diperbarui!\n\nData lama telah ditimpa.` +// : `Wajah ${user!.nama_mahasiswa || user!.name} (${user!.nim}) berhasil terdaftar!\n\nEmbedding tersimpan di: face_mahasiswa/${user!.nim}/`, +// [{ text: "OK", onPress: handleRetake }] +// ); +// } else { +// if (result.message?.includes("sudah ter-enroll")) { +// if (forceUpdate) { +// setIsAlreadyRegistered(true); +// Alert.alert( +// "Berhasil Diperbarui! 🎉", +// `Wajah ${user!.nama_mahasiswa || user!.name} (${user!.nim}) berhasil diperbarui!\n\nData lama telah ditimpa.`, +// [{ text: "OK", onPress: handleRetake }] +// ); +// } else { +// Alert.alert( +// "Info", +// "Wajah Anda sudah terdaftar sebelumnya.\n\nJika ingin memperbarui foto, silakan ambil foto baru dan pilih 'Perbarui'.", +// [{ text: "OK", onPress: handleRetake }] +// ); +// } +// } else if (result.message?.includes("ArcFace model")) { +// Alert.alert( +// "Model Error", +// "Sistem Face Recognition belum siap. Hubungi admin untuk mengaktifkan model ArcFace.", +// [{ text: "OK" }] +// ); +// } else { +// Alert.alert("Gagal", result.message || "Gagal menyimpan foto wajah."); +// } +// } +// } catch (error) { +// console.error("Enrollment error:", error); +// Alert.alert("Error", "Gagal mengirim foto. Periksa koneksi internet dan coba lagi."); +// } finally { +// setIsSubmitting(false); +// } +// }; + +// // ─── Render: Permission Loading ─────────────────────────────────────────── + +// if (!permission) { +// return ( +// +// +// +// +// Memuat kamera... +// +// +// ); +// } + +// if (!permission.granted) { +// return ( +// +// +// +// +// +// +// +// Izinkan Akses Kamera +// +// Kami membutuhkan akses kamera untuk mengambil foto wajah Anda untuk verifikasi kehadiran. +// +// +// +// +// Izinkan Kamera +// +// +// +// +// +// ); +// } + +// // ─── Render: Main Screen ───────────────────────────────────────────────── + +// return ( +// +// + +// {/* Header */} +// +// +// +// +// +// +// +// {isAlreadyRegistered ? "Update Wajah" : "Register Wajah"} +// +// +// {isCheckingStatus +// ? "Memuat..." +// : isAlreadyRegistered +// ? "✓ Sudah terdaftar" +// : "Ambil foto untuk verifikasi"} +// +// +// +// +// {/* Gallery Button - Select one by one with crop editor */} +// { +// if (allCaptured || showPreview) return; + +// const remaining = totalRequired - capturedImages.length; +// if (remaining <= 0) return; + +// try { +// const result = await ImagePicker.launchImageLibraryAsync({ +// mediaTypes: ["images"], +// allowsMultipleSelection: false, +// quality: 1, +// }); + +// if (!result.canceled && result.assets.length > 0) { +// const selectedPhoto = result.assets[0]; +// setSelectedImageUri(selectedPhoto.uri); +// setShowCropEditor(true); +// } +// } catch (err) { +// Alert.alert("Error", "Gagal memilih gambar dari galeri"); +// } +// }} +// > +// +// +// {/* Delete Button */} +// {isAlreadyRegistered && ( +// +// +// +// )} +// +// + +// {/* Scrollable body */} +// +// {/* Pose guide — DI ATAS oval, tidak menghalangi wajah */} +// {!showPreview && !allCaptured && ( +// +// +// {currentPose.icon} +// {currentPose.label} +// {currentPose.description} +// +// +// )} + +// {/* Camera wrapper */} +// +// +// {(showPreview || allCaptured) && capturedImages.length > 0 ? ( +// /* Tampilkan foto depan (pertama) sebagai preview utama di oval */ +// +// ) : ( +// <> +// +// {/* Flash overlay */} +// +// +// )} +// + +// {!showPreview && !allCaptured && } + +// {(showPreview || allCaptured) && ( +// +// +// +// {allCaptured ? `${capturedImages.length} foto selesai` : "Foto Tertangkap"} +// +// +// )} +// + +// {/* Thumbnails — di bawah oval, tampilkan semua foto kecil-kecil */} +// {capturedImages.length > 0 && (showPreview || allCaptured) && ( +// +// {capturedImages.map((uri, idx) => ( +// +// +// +// {idx < 2 ? 'Depan' : idx < 4 ? 'Kanan' : 'Kiri'} {(idx % 2) + 1} +// +// +// ))} +// +// )} + +// {/* Pose progress stepper */} +// +// {POSES.map((pose, idx) => { +// const isDone = idx < currentPoseIndex || (idx === currentPoseIndex && allCaptured); +// const isCurrent = idx === currentPoseIndex && !allCaptured; +// return ( +// +// +// {isDone && } +// +// {pose.label.replace('Hadap ', '')} +// {idx < POSES.length - 1 && ( +// +// )} +// +// ); +// })} +// + +// {/* Status badge */} +// +// +// +// +// {showPreview +// ? `${capturedImages.length} foto siap dikirim` +// : allCaptured +// ? "Semua pose selesai! Ketuk untuk simpan" +// : isCapturing +// ? "📸 Mengambil 2 foto..." +// : `${currentPose.label} — Ketuk tombol untuk ambil foto`} +// +// +// + +// {/* Instructions */} +// +// +// +// +// {showPreview || allCaptured +// ? "6 foto siap dikirim ke server" +// : "Tekan tombol kamera atau galeri untuk ambil foto"} +// +// +// +// +// +// Tombol galeri: pilih foto satu per satu, di-crop otomatis sesuai oval +// +// +// +// +// +// {allCaptured ? 'Semua 3 pose sudah diambil' : `Langkah ${currentPoseIndex + 1}/3: ${currentPose.description}`} +// +// +// +// +// Lepas kacamata, topi, atau masker +// +// +// + +// {/* Bottom Action — fixed */} +// +// {showPreview ? ( +// +// +// +// Ulangi +// + +// +// +// +// {isSubmitting ? ( +// <> +// +// Mengirim... +// +// ) : ( +// <> +// +// Simpan {capturedImages.length} Foto +// +// )} +// +// +// +// +// ) : ( +// +// +// setShowPreview(true) : handleBurstCapture} +// disabled={isCapturing} +// activeOpacity={0.85} +// > +// +// {isCapturing ? ( +// +// ) : allCaptured ? ( +// +// ) : ( +// +// )} +// +// +// +// +// +// {isCapturing +// ? "Mengambil 2 foto..." +// : allCaptured +// ? `${capturedImages.length} foto — Ketuk untuk preview` +// : `Ketuk untuk ambil foto ${currentPose.label}`} +// +// +// )} +// + +// {/* Crop Editor Modal */} +// { +// setShowCropEditor(false); +// setCapturedImages(prev => [...prev, croppedUri]); + +// const newCount = capturedImages.length + 1; +// const nextPose = Math.floor(newCount / CAPTURES_PER_POSE); + +// // Update pose index +// if (newCount % CAPTURES_PER_POSE === 0 && nextPose < POSES.length) { +// setCurrentPoseIndex(Math.min(nextPose, POSES.length - 1)); +// } + +// // If all 6 photos, go to preview +// if (newCount >= totalRequired) { +// setShowPreview(true); +// setCurrentPoseIndex(POSES.length - 1); +// } +// }} +// onCancel={() => { +// setShowCropEditor(false); +// setSelectedImageUri(''); +// }} +// /> +// +// ); +// } + +// // ─── Styles ───────────────────────────────────────────────────────────────── + +// const styles = StyleSheet.create({ +// container: { +// flex: 1, +// backgroundColor: THEME.bg, +// }, + +// // ─── Header ─────────────────────────────────────────────────────────────── + +// header: { +// flexDirection: "row", +// alignItems: "center", +// justifyContent: "space-between", +// paddingTop: Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) + 10 : 55, +// paddingHorizontal: 20, +// paddingBottom: 14, +// backgroundColor: THEME.bg, +// borderBottomWidth: 1, +// borderBottomColor: THEME.border, +// }, +// headerLeft: { +// flexDirection: "row", +// alignItems: "center", +// gap: 12, +// }, +// headerIconContainer: { +// width: 42, +// height: 42, +// borderRadius: 12, +// backgroundColor: THEME.primaryBg, +// alignItems: "center", +// justifyContent: "center", +// }, +// headerTitle: { +// fontSize: 18, +// fontWeight: "800", +// color: THEME.textPrimary, +// letterSpacing: 0.2, +// }, +// headerSubtitle: { +// fontSize: 12, +// color: THEME.primary, +// marginTop: 1, +// }, +// headerRightButtons: { +// flexDirection: "row", +// alignItems: "center", +// gap: 8, +// }, +// iconButton: { +// width: 42, +// height: 42, +// borderRadius: 12, +// backgroundColor: THEME.bgInput, +// alignItems: "center", +// justifyContent: "center", +// borderWidth: 1, +// borderColor: THEME.border, +// }, +// deleteButton: { +// borderColor: `${THEME.danger}40`, +// backgroundColor: `${THEME.danger}10`, +// }, + +// // ─── Scroll body ────────────────────────────────────────────────────────── + +// scrollView: { +// flex: 1, +// }, +// scrollContent: { +// alignItems: "center", +// paddingTop: 16, +// paddingBottom: 160, +// }, + +// // ─── Camera area ────────────────────────────────────────────────────────── + +// cameraWrapper: { +// alignSelf: "center", +// width: CAMERA_SIZE, +// height: CAMERA_SIZE, +// position: "relative", +// borderRadius: 24, +// backgroundColor: "#FFFFFF", +// }, +// ovalClip: { +// position: "absolute", +// top: OVAL_TOP, +// left: OVAL_LEFT, +// width: OVAL_WIDTH, +// height: OVAL_HEIGHT, +// borderRadius: OVAL_WIDTH / 2, +// overflow: "hidden", +// backgroundColor: THEME.bgSecondary, +// }, +// cameraInner: { +// position: "absolute", +// top: -OVAL_TOP, +// left: -OVAL_LEFT, +// width: CAMERA_SIZE, +// height: CAMERA_SIZE, +// }, +// previewImage: { +// width: "100%", +// height: "100%", +// }, +// ovalRing: { +// position: "absolute", +// top: OVAL_TOP, +// left: OVAL_LEFT, +// width: OVAL_WIDTH, +// height: OVAL_HEIGHT, +// borderRadius: OVAL_WIDTH / 2, +// borderWidth: 3, +// borderColor: "#FFFFFF", +// zIndex: 10, +// }, +// flashOverlay: { +// ...StyleSheet.absoluteFillObject, +// backgroundColor: "#FFFFFF", +// zIndex: 12, +// }, + +// // ─── Pose guide (above oval) ──────────────────────────────────────────────── + +// poseGuideAbove: { +// alignItems: "center", +// marginBottom: 12, +// }, +// poseGuideBox: { +// backgroundColor: "rgba(0,0,0,0.82)", +// paddingHorizontal: 20, +// paddingVertical: 12, +// borderRadius: 16, +// alignItems: "center", +// borderWidth: 2, +// gap: 3, +// }, +// poseIcon: { +// fontSize: 26, +// }, +// poseLabel: { +// fontSize: 16, +// fontWeight: "700", +// color: "#FFFFFF", +// }, +// poseDesc: { +// fontSize: 12, +// color: "rgba(255,255,255,0.75)", +// }, + +// // ─── Preview badge ──────────────────────────────────────────────────────── + +// previewBadge: { +// position: "absolute", +// top: 12, +// right: 12, +// flexDirection: "row", +// alignItems: "center", +// gap: 6, +// backgroundColor: "rgba(255,255,255,0.95)", +// paddingHorizontal: 10, +// paddingVertical: 5, +// borderRadius: 20, +// borderWidth: 1, +// borderColor: THEME.border, +// zIndex: 20, +// }, +// previewBadgeText: { +// fontSize: 11, +// fontWeight: "600", +// color: THEME.success, +// }, + +// // ─── Thumbnail row (below oval) ─────────────────────────────────────────── + +// thumbnailRow: { +// flexDirection: "row", +// justifyContent: "center", +// flexWrap: "wrap", +// gap: 10, +// marginTop: 14, +// paddingHorizontal: 20, +// }, +// thumbnailWrapper: { +// alignItems: "center", +// gap: 4, +// }, +// thumbnailBelow: { +// width: 52, +// height: 64, +// borderRadius: 10, +// borderWidth: 2, +// borderColor: THEME.border, +// }, +// thumbnailLabel: { +// fontSize: 9, +// fontWeight: "600", +// color: THEME.textMuted, +// }, + +// // ─── Stepper ────────────────────────────────────────────────────────────── + +// stepperContainer: { +// flexDirection: "row", +// alignItems: "center", +// justifyContent: "center", +// marginTop: 16, +// paddingHorizontal: 16, +// }, +// stepperItem: { +// alignItems: "center", +// flexDirection: "row", +// gap: 4, +// }, +// stepperDot: { +// width: 22, +// height: 22, +// borderRadius: 11, +// alignItems: "center", +// justifyContent: "center", +// }, +// stepperLabel: { +// fontSize: 10, +// color: THEME.textMuted, +// fontWeight: "500", +// marginRight: 2, +// }, +// stepperLine: { +// width: 16, +// height: 2, +// backgroundColor: THEME.border, +// marginHorizontal: 2, +// borderRadius: 1, +// }, + +// // ─── Status badge ───────────────────────────────────────────────────────── + +// statusWrapper: { +// alignItems: "center", +// marginTop: 12, +// paddingHorizontal: 24, +// }, +// statusBadge: { +// flexDirection: "row", +// alignItems: "center", +// paddingHorizontal: 18, +// paddingVertical: 9, +// borderRadius: 50, +// borderWidth: 1, +// gap: 8, +// }, +// statusDot: { +// width: 8, +// height: 8, +// borderRadius: 4, +// }, +// statusText: { +// fontSize: 13, +// fontWeight: "600", +// }, + +// // ─── Info panel ─────────────────────────────────────────────────────────── + +// infoPanel: { +// marginHorizontal: 20, +// marginTop: 14, +// width: CAMERA_SIZE, +// backgroundColor: THEME.bgCard, +// borderRadius: 16, +// padding: 14, +// borderWidth: 1, +// borderColor: THEME.border, +// gap: 10, +// }, +// infoPanelRow: { +// flexDirection: "row", +// alignItems: "center", +// gap: 8, +// }, +// infoPanelText: { +// fontSize: 13, +// color: THEME.textSecondary, +// flex: 1, +// lineHeight: 18, +// }, + +// // ─── Bottom capture section ─────────────────────────────────────────────── + +// captureSection: { +// position: "absolute", +// bottom: 0, +// left: 0, +// right: 0, +// alignItems: "center", +// paddingTop: 16, +// paddingBottom: Platform.OS === "android" ? 20 : 32, +// backgroundColor: THEME.bg, +// borderTopWidth: 1, +// borderTopColor: THEME.border, +// }, +// captureContainer: { +// alignItems: "center", +// gap: 10, +// }, +// captureButtonOuter: { +// alignItems: "center", +// justifyContent: "center", +// }, +// captureButton: { +// width: 72, +// height: 72, +// borderRadius: 36, +// alignItems: "center", +// justifyContent: "center", +// shadowColor: THEME.primary, +// shadowOffset: { width: 0, height: 6 }, +// shadowOpacity: 0.4, +// shadowRadius: 16, +// elevation: 8, +// }, +// captureRing: { +// position: "absolute", +// width: 86, +// height: 86, +// borderRadius: 43, +// borderWidth: 2.5, +// opacity: 0.25, +// }, +// captureHintBottom: { +// fontSize: 13, +// color: THEME.textSecondary, +// fontWeight: "500", +// }, + +// // ─── Preview actions ────────────────────────────────────────────────────── + +// previewActions: { +// flexDirection: "row", +// alignItems: "center", +// gap: 16, +// }, +// retakeButton: { +// alignItems: "center", +// justifyContent: "center", +// gap: 4, +// width: 68, +// height: 68, +// borderRadius: 34, +// backgroundColor: THEME.bgInput, +// borderWidth: 2, +// borderColor: THEME.border, +// }, +// retakeButtonText: { +// fontSize: 11, +// fontWeight: "600", +// color: THEME.primary, +// }, +// confirmButton: { +// borderRadius: 40, +// overflow: "hidden", +// shadowColor: THEME.success, +// shadowOffset: { width: 0, height: 6 }, +// shadowOpacity: 0.35, +// shadowRadius: 16, +// elevation: 8, +// }, +// confirmButtonGradient: { +// flexDirection: "row", +// alignItems: "center", +// justifyContent: "center", +// gap: 8, +// paddingHorizontal: 28, +// paddingVertical: 18, +// borderRadius: 40, +// }, +// confirmButtonText: { +// fontSize: 15, +// fontWeight: "700", +// color: "#fff", +// }, + +// // ─── Permission screen ──────────────────────────────────────────────────── + +// permissionContainer: { +// flex: 1, +// alignItems: "center", +// justifyContent: "center", +// padding: 24, +// }, +// permissionCard: { +// alignItems: "center", +// backgroundColor: THEME.bgCard, +// borderRadius: 24, +// padding: 32, +// borderWidth: 1, +// borderColor: THEME.border, +// width: "100%", +// maxWidth: 340, +// shadowColor: THEME.shadowDark, +// shadowOffset: { width: 0, height: 4 }, +// shadowOpacity: 1, +// shadowRadius: 16, +// elevation: 4, +// }, +// permissionIconBg: { +// width: 96, +// height: 96, +// borderRadius: 48, +// backgroundColor: THEME.primaryBg, +// alignItems: "center", +// justifyContent: "center", +// marginBottom: 20, +// }, +// permissionTitle: { +// fontSize: 20, +// fontWeight: "700", +// color: THEME.textPrimary, +// marginBottom: 12, +// textAlign: "center", +// }, +// permissionDesc: { +// fontSize: 14, +// color: THEME.textSecondary, +// textAlign: "center", +// lineHeight: 22, +// marginBottom: 28, +// paddingHorizontal: 8, +// }, +// permissionButton: { +// borderRadius: 14, +// overflow: "hidden", +// width: "100%", +// }, +// permissionButtonGradient: { +// flexDirection: "row", +// alignItems: "center", +// justifyContent: "center", +// gap: 10, +// paddingVertical: 16, +// borderRadius: 14, +// }, +// permissionButtonText: { +// color: "#fff", +// fontSize: 16, +// fontWeight: "700", +// }, + +// // ─── Loading ─────────────────────────────────────────────────────────────── + +// loadingContainer: { +// flex: 1, +// alignItems: "center", +// justifyContent: "center", +// gap: 16, +// }, +// loadingText: { +// fontSize: 14, +// color: THEME.textSecondary, +// }, +// }); + + +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Alert, + Dimensions, + Platform, + StatusBar, + Animated, + Image, + ScrollView, + ActivityIndicator, + Vibration, + Modal, + PanResponder, +} from "react-native"; +import { CameraView, CameraType, useCameraPermissions } from "expo-camera"; +import { Ionicons } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import * as ImageManipulator from "expo-image-manipulator"; +import * as ImagePicker from "expo-image-picker"; +import { useAuth } from "@/contexts/AuthContext"; +import { API_BASE } from "@/constants/config"; + +// ─── Pose Types ───────────────────────────────────────────────────────────── + +type PoseType = 'center' | 'right' | 'left'; + +interface PoseConfig { + id: PoseType; + label: string; + icon: string; + description: string; + color: string; +} + +const POSES: PoseConfig[] = [ + { id: 'center', label: 'Hadap Depan', icon: '👤', description: 'Lihat kamera secara langsung', color: '#2563EB' }, + { id: 'right', label: 'Hadap Kanan', icon: '👉', description: 'Putar kepala ke kanan ~30°', color: '#8B5CF6' }, + { id: 'left', label: 'Hadap Kiri', icon: '👈', description: 'Putar kepala ke kiri ~30°', color: '#EC4899' }, +]; + +const CAPTURES_PER_POSE = 2; // burst: 2 foto per klik + +// ─── Constants ────────────────────────────────────────────────────────────── + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); +const CAMERA_SIZE = SCREEN_WIDTH * 0.85; +const OVAL_WIDTH = CAMERA_SIZE * 0.55; +const OVAL_HEIGHT = CAMERA_SIZE * 0.75; +const OVAL_TOP = (CAMERA_SIZE - OVAL_HEIGHT) / 2; +const OVAL_LEFT = (CAMERA_SIZE - OVAL_WIDTH) / 2; + +// Crop Editor constants +const EDITOR_SIZE = SCREEN_WIDTH * 0.9; +const EDITOR_OVAL_WIDTH = EDITOR_SIZE * 0.6; +const EDITOR_OVAL_HEIGHT = EDITOR_SIZE * 0.8; + +// ─── Theme Colors ─────────────────────────────────────────────────────────── + +const THEME = { + bg: "#FFFFFF", + bgSecondary: "#F8FAFC", + bgCard: "#FFFFFF", + bgInput: "#F1F5F9", + border: "#E2E8F0", + borderLight: "#F1F5F9", + textPrimary: "#1E293B", + textSecondary: "#64748B", + textMuted: "#94A3B8", + primary: "#2563EB", + primaryLight: "#3B82F6", + primaryDark: "#1D4ED8", + primaryBg: "#EFF6FF", + primaryBgDark: "#DBEAFE", + success: "#10B981", + successBg: "#ECFDF5", + warning: "#F59E0B", + warningBg: "#FFFBEB", + danger: "#EF4444", + dangerBg: "#FEF2F2", + shadow: "rgba(0, 0, 0, 0.06)", + shadowDark: "rgba(0, 0, 0, 0.08)", +}; + +// ─── Oval Mask Overlay ────────────────────────────────────────────────────── + +function OvalMaskOverlay() { + return ; +} + +// ─── Crop Editor Modal Component ─────────────────────────────────────────── +// Approach: fixed oval mask, user drags/zooms the IMAGE behind it. +// This ensures what-you-see-is-what-you-get when confirming the crop. + +interface CropEditorProps { + visible: boolean; + imageUri: string; + onConfirm: (croppedUri: string) => void; + onCancel: () => void; +} + +function CropEditor({ visible, imageUri, onConfirm, onCancel }: CropEditorProps) { + const [isProcessing, setIsProcessing] = useState(false); + + // Original image pixel dimensions + const [originalWidth, setOriginalWidth] = useState(0); + const [originalHeight, setOriginalHeight] = useState(0); + + // Display dimensions. The image uses COVER behavior so the oval is always filled. + const [displayWidth, setDisplayWidth] = useState(EDITOR_SIZE); + const [displayHeight, setDisplayHeight] = useState(EDITOR_SIZE); + + // Image offset from the centered position + const [imageOffsetX, setImageOffsetX] = useState(0); + const [imageOffsetY, setImageOffsetY] = useState(0); + const [scale, setScale] = useState(1); + + // IMPORTANT: refs are the single source of truth for gesture/crop values. + // Using React state directly during handleConfirm can read one render behind + // when the user presses the check button immediately after dragging. + const offsetXRef = useRef(0); + const offsetYRef = useRef(0); + const scaleRef = useRef(1); + const displayWRef = useRef(EDITOR_SIZE); + const displayHRef = useRef(EDITOR_SIZE); + const gestureStartOffset = useRef({ x: 0, y: 0 }); + + // Load the original image dimensions and calculate the initial COVER size. + useEffect(() => { + if (!imageUri) return; + + Image.getSize( + imageUri, + (w, h) => { + setOriginalWidth(w); + setOriginalHeight(h); + + const fitScale = Math.max( + EDITOR_SIZE / w, + EDITOR_SIZE / h + ); + + const dw = w * fitScale; + const dh = h * fitScale; + + setDisplayWidth(dw); + setDisplayHeight(dh); + displayWRef.current = dw; + displayHRef.current = dh; + }, + (error) => console.warn("Failed to get image size:", error) + ); + }, [imageUri]); + + // Keep the oval completely inside the displayed image. + const clampOffset = useCallback( + (ox: number, oy: number, currentScale: number) => { + const scaledW = displayWRef.current * currentScale; + const scaledH = displayHRef.current * currentScale; + + const maxOffsetX = Math.max( + 0, + (scaledW - EDITOR_OVAL_WIDTH) / 2 + ); + const maxOffsetY = Math.max( + 0, + (scaledH - EDITOR_OVAL_HEIGHT) / 2 + ); + + return { + x: Math.max(-maxOffsetX, Math.min(maxOffsetX, ox)), + y: Math.max(-maxOffsetY, Math.min(maxOffsetY, oy)), + }; + }, + [] + ); + + // PanResponder moves the image behind the fixed oval. + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: (_, gs) => + Math.abs(gs.dx) > 2 || Math.abs(gs.dy) > 2, + + onPanResponderGrant: () => { + gestureStartOffset.current = { + x: offsetXRef.current, + y: offsetYRef.current, + }; + }, + + onPanResponderMove: (_, gestureState) => { + const newX = + gestureStartOffset.current.x + gestureState.dx; + const newY = + gestureStartOffset.current.y + gestureState.dy; + + const clamped = clampOffset( + newX, + newY, + scaleRef.current + ); + + // Update refs FIRST. These values are used by handleConfirm. + offsetXRef.current = clamped.x; + offsetYRef.current = clamped.y; + + // State is only for rendering. + setImageOffsetX(clamped.x); + setImageOffsetY(clamped.y); + }, + + onPanResponderRelease: () => {}, + onPanResponderTerminate: () => {}, + }) + ).current; + + const applyZoom = useCallback( + (newScale: number) => { + const safeScale = Math.max(1, Math.min(3, newScale)); + + const clamped = clampOffset( + offsetXRef.current, + offsetYRef.current, + safeScale + ); + + offsetXRef.current = clamped.x; + offsetYRef.current = clamped.y; + scaleRef.current = safeScale; + + setImageOffsetX(clamped.x); + setImageOffsetY(clamped.y); + setScale(safeScale); + }, + [clampOffset] + ); + + const zoomIn = () => { + applyZoom(scaleRef.current + 0.2); + }; + + const zoomOut = () => { + applyZoom(scaleRef.current - 0.2); + }; + + const resetPosition = () => { + offsetXRef.current = 0; + offsetYRef.current = 0; + scaleRef.current = 1; + + setImageOffsetX(0); + setImageOffsetY(0); + setScale(1); + }; + + const handleConfirm = async () => { + if (originalWidth <= 0 || originalHeight <= 0) { + Alert.alert("Error", "Ukuran foto belum siap. Silakan coba lagi."); + return; + } + + setIsProcessing(true); + + try { + // ================================================================ + // IMPORTANT: + // Read ALL current gesture values from refs, not React state. + // This prevents the crop from using an older position when the + // user presses ✓ immediately after dragging/zooming. + // ================================================================ + const currentOffsetX = offsetXRef.current; + const currentOffsetY = offsetYRef.current; + const currentScale = scaleRef.current; + const currentDisplayWidth = displayWRef.current; + const currentDisplayHeight = displayHRef.current; + + const scaledWidth = currentDisplayWidth * currentScale; + const scaledHeight = currentDisplayHeight * currentScale; + + // Fixed oval position inside the editor. + const ovalLeft = + (EDITOR_SIZE - EDITOR_OVAL_WIDTH) / 2; + const ovalTop = + (EDITOR_SIZE - EDITOR_OVAL_HEIGHT) / 2; + + // Current image top-left inside the editor. + const imageLeft = + (EDITOR_SIZE - scaledWidth) / 2 + currentOffsetX; + const imageTop = + (EDITOR_SIZE - scaledHeight) / 2 + currentOffsetY; + + // Oval position relative to the image. + const cropXOnDisplay = ovalLeft - imageLeft; + const cropYOnDisplay = ovalTop - imageTop; + + // Convert displayed pixels back to ORIGINAL image pixels. + const originalPerDisplayX = + originalWidth / scaledWidth; + const originalPerDisplayY = + originalHeight / scaledHeight; + + let cropOriginX = Math.round( + cropXOnDisplay * originalPerDisplayX + ); + let cropOriginY = Math.round( + cropYOnDisplay * originalPerDisplayY + ); + + let cropWidth = Math.round( + EDITOR_OVAL_WIDTH * originalPerDisplayX + ); + let cropHeight = Math.round( + EDITOR_OVAL_HEIGHT * originalPerDisplayY + ); + + // Clamp to the original image bounds. + cropOriginX = Math.max( + 0, + Math.min(cropOriginX, originalWidth - 1) + ); + cropOriginY = Math.max( + 0, + Math.min(cropOriginY, originalHeight - 1) + ); + + cropWidth = Math.max( + 10, + Math.min(cropWidth, originalWidth - cropOriginX) + ); + cropHeight = Math.max( + 10, + Math.min(cropHeight, originalHeight - cropOriginY) + ); + + const cropRegion = { + originX: cropOriginX, + originY: cropOriginY, + width: cropWidth, + height: cropHeight, + }; + + console.log("=== CROP DEBUG ==="); + console.log({ + originalWidth, + originalHeight, + currentDisplayWidth, + currentDisplayHeight, + currentScale, + currentOffsetX, + currentOffsetY, + scaledWidth, + scaledHeight, + imageLeft, + imageTop, + ovalLeft, + ovalTop, + cropXOnDisplay, + cropYOnDisplay, + cropRegion, + }); + + const result = await ImageManipulator.manipulateAsync( + imageUri, + [ + { crop: cropRegion }, + { resize: { width: 800 } }, + ], + { + compress: 0.9, + format: ImageManipulator.SaveFormat.JPEG, + } + ); + + onConfirm(result.uri); + } catch (error) { + console.error("Crop error:", error); + Alert.alert( + "Error", + "Gagal memproses foto. Silakan coba lagi." + ); + } finally { + setIsProcessing(false); + } + }; + + // Reset state whenever a new image/modal is opened. + useEffect(() => { + if (!visible) return; + + offsetXRef.current = 0; + offsetYRef.current = 0; + scaleRef.current = 1; + + setImageOffsetX(0); + setImageOffsetY(0); + setScale(1); + }, [visible, imageUri]); + + return ( + + + + + + + + + Atur Posisi Wajah + + + + {isProcessing ? ( + + ) : ( + + )} + + + + + + Geser foto untuk menyesuaikan posisi wajah di dalam oval + + + + + + {/* + Image is rendered with the exact same dimensions used by + handleConfirm() to calculate the crop coordinates. + This creates a 1:1 relationship between the visible oval + and the final cropped image. + */} + + + {/* Dark overlay with transparent oval cutout */} + + + + + + + + + + + + + + + + + + + + + + + + + + {Math.round(scale * 100)}% + + + + + + + + + + Reset + + + + + ); +} + +const OVAL_TOP_OFFSET = (EDITOR_SIZE - EDITOR_OVAL_HEIGHT) / 2; +const OVAL_LEFT_OFFSET = (EDITOR_SIZE - EDITOR_OVAL_WIDTH) / 2; + +const cropStyles = StyleSheet.create({ + modalContainer: { + flex: 1, + backgroundColor: '#000', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) + 10 : 50, + paddingHorizontal: 16, + paddingBottom: 12, + backgroundColor: 'rgba(0,0,0,0.9)', + }, + headerButton: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: 18, + fontWeight: '700', + color: '#fff', + }, + instructionContainer: { + paddingHorizontal: 20, + paddingVertical: 12, + backgroundColor: 'rgba(0,0,0,0.7)', + }, + instructionText: { + fontSize: 14, + color: 'rgba(255,255,255,0.8)', + textAlign: 'center', + }, + editorContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, + imageWrapper: { + width: EDITOR_SIZE, + height: EDITOR_SIZE, + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + borderRadius: 12, + }, + fullImage: { + position: 'absolute', + }, + // Dark overlay pieces around the oval cutout + overlayContainer: { + ...StyleSheet.absoluteFillObject, + }, + darkBandHorizontal: { + width: '100%', + height: OVAL_TOP_OFFSET, + backgroundColor: 'rgba(0,0,0,0.55)', + }, + middleRow: { + flexDirection: 'row', + height: EDITOR_OVAL_HEIGHT, + }, + darkBandVertical: { + width: OVAL_LEFT_OFFSET, + height: '100%', + backgroundColor: 'rgba(0,0,0,0.55)', + }, + ovalHole: { + width: EDITOR_OVAL_WIDTH, + height: EDITOR_OVAL_HEIGHT, + borderRadius: EDITOR_OVAL_WIDTH / 2, + overflow: 'hidden', + // transparent — lets the image show through + }, + ovalBorder: { + ...StyleSheet.absoluteFillObject, + borderRadius: EDITOR_OVAL_WIDTH / 2, + borderWidth: 3, + borderColor: '#fff', + }, + controlsContainer: { + paddingHorizontal: 20, + paddingVertical: 20, + backgroundColor: 'rgba(0,0,0,0.9)', + borderTopWidth: 1, + borderTopColor: 'rgba(255,255,255,0.1)', + }, + zoomControls: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 24, + marginBottom: 16, + }, + zoomButton: { + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: 'rgba(255,255,255,0.15)', + alignItems: 'center', + justifyContent: 'center', + }, + zoomLabel: { + fontSize: 16, + fontWeight: '600', + color: '#fff', + minWidth: 60, + textAlign: 'center', + }, + resetButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 12, + paddingHorizontal: 24, + backgroundColor: 'rgba(255,255,255,0.1)', + borderRadius: 12, + }, + resetText: { + fontSize: 14, + color: THEME.textSecondary, + fontWeight: '500', + }, +}); + +// ─── Main Component ──────────────────────────────────────────────────────────── + +export default function RegisterFotoScreen() { + const { user, token } = useAuth(); + + // ── State ───────────────────────────────────────────────────────────────── + const [permission, requestPermission] = useCameraPermissions(); + const [facing] = useState("front"); + const [isCapturing, setIsCapturing] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [capturedImages, setCapturedImages] = useState([]); + const [showPreview, setShowPreview] = useState(false); + const [captureAnim] = useState(new Animated.Value(1)); + const [flashAnim] = useState(new Animated.Value(0)); + const [isAlreadyRegistered, setIsAlreadyRegistered] = useState(false); + const [isCheckingStatus, setIsCheckingStatus] = useState(true); + const cameraRef = useRef(null); + + // ── Crop Editor State ───────────────────────────────────────────────────── + const [showCropEditor, setShowCropEditor] = useState(false); + const [selectedImageUri, setSelectedImageUri] = useState(''); + + // ── Guided Pose State ───────────────────────────────────────────────────── + const [currentPoseIndex, setCurrentPoseIndex] = useState(0); + + const currentPose = POSES[currentPoseIndex]; + const totalRequired = POSES.length * CAPTURES_PER_POSE; + const allCaptured = capturedImages.length >= totalRequired; + + // ── Reset ───────────────────────────────────────────────────────────────── + const resetCapture = useCallback(() => { + setCapturedImages([]); + setCurrentPoseIndex(0); + setShowPreview(false); + }, []); + + // ── Check registration status ───────────────────────────────────────────── + useEffect(() => { + const checkRegistrationStatus = async () => { + if (!token || !user?.nim) { + setIsCheckingStatus(false); + return; + } + + try { + const response = await fetch(`${API_BASE}/arcface/status/${user.nim}`, { + method: "GET", + headers: { "Authorization": `Bearer ${token}` }, + }); + + if (response.ok || response.status === 200) { + const result = await response.json(); + setIsAlreadyRegistered(result.is_registered === true); + } else { + setIsAlreadyRegistered(false); + } + } catch (error) { + console.warn("Gagal cek status registrasi:", error); + setIsAlreadyRegistered(false); + } finally { + setIsCheckingStatus(false); + } + }; + + checkRegistrationStatus(); + }, [token, user?.nim]); + + // ── Crop to oval region ─────────────────────────────────────────────────── + const cropToOvalRegion = async ( + photoUri: string, + photoWidth: number, + photoHeight: number, + isFront: boolean + ): Promise => { + try { + const containerSize = CAMERA_SIZE; + const scale = Math.max(containerSize / photoWidth, containerSize / photoHeight); + const displayedWidth = photoWidth * scale; + const displayedHeight = photoHeight * scale; + const offsetX = (displayedWidth - containerSize) / 2; + const offsetY = (displayedHeight - containerSize) / 2; + + let originX = (OVAL_LEFT + offsetX) / scale; + let originY = (OVAL_TOP + offsetY) / scale; + let width = OVAL_WIDTH / scale; + let height = OVAL_HEIGHT / scale; + + originX = Math.max(0, Math.min(originX, photoWidth - 1)); + originY = Math.max(0, Math.min(originY, photoHeight - 1)); + width = Math.max(1, Math.min(width, photoWidth - originX)); + height = Math.max(1, Math.min(height, photoHeight - originY)); + + const cropRegion = { + originX: Math.round(originX), + originY: Math.round(originY), + width: Math.round(width), + height: Math.round(height), + }; + + const actions: ImageManipulator.Action[] = []; + if (isFront) { + actions.push({ flip: ImageManipulator.FlipType.Horizontal }); + } + actions.push({ crop: cropRegion }); + actions.push({ resize: { width: 800 } }); + + const manipResult = await ImageManipulator.manipulateAsync( + photoUri, + actions, + { compress: 0.9, format: ImageManipulator.SaveFormat.JPEG } + ); + + return manipResult.uri; + } catch (error) { + console.warn("Gagal melakukan crop oval:", error); + return photoUri; + } + }; + + // ── Burst capture: 1 klik = 2 foto ─────────────────────────────────────── + const handleBurstCapture = async () => { + if (!cameraRef.current || isCapturing || allCaptured) return; + + // Capture animation + Animated.sequence([ + Animated.timing(captureAnim, { toValue: 0.92, duration: 80, useNativeDriver: true }), + Animated.spring(captureAnim, { toValue: 1, friction: 4, useNativeDriver: true }), + ]).start(); + + setIsCapturing(true); + + try { + const newPhotos: string[] = []; + + for (let i = 0; i < CAPTURES_PER_POSE; i++) { + // Flash effect + Animated.sequence([ + Animated.timing(flashAnim, { toValue: 1, duration: 60, useNativeDriver: true }), + Animated.timing(flashAnim, { toValue: 0, duration: 150, useNativeDriver: true }), + ]).start(); + + const photo = await cameraRef.current.takePictureAsync({ + quality: 0.85, + shutterSound: false, + }); + + if (photo?.uri) { + const croppedUri = photo.width && photo.height + ? await cropToOvalRegion(photo.uri, photo.width, photo.height, facing === "front") + : photo.uri; + + newPhotos.push(croppedUri); + Vibration.vibrate(30); + } + + // Jeda singkat antara 2 foto burst + if (i < CAPTURES_PER_POSE - 1) { + await new Promise(resolve => setTimeout(resolve, 400)); + } + } + + // Update state + setCapturedImages(prev => [...prev, ...newPhotos]); + + // Pindah ke pose berikutnya atau selesai + if (currentPoseIndex < POSES.length - 1) { + setCurrentPoseIndex(prev => prev + 1); + } + // Jika ini pose terakhir, allCaptured akan jadi true di render berikutnya + + } catch (error) { + Alert.alert("Error", "Gagal mengambil foto. Silakan coba lagi."); + } finally { + setIsCapturing(false); + } + }; + + const handleRetake = () => { + resetCapture(); + }; + + // ── Handle Delete ───────────────────────────────────────────────────────── + const handleDelete = async () => { + if (!user?.nim || !token) { + Alert.alert("Error", "Silakan login terlebih dahulu."); + return; + } + + Alert.alert( + "Hapus Registrasi?", + `Apakah Anda yakin ingin menghapus data wajah untuk NIM ${user.nim}? Anda perlu registrasi ulang di kemudian hari.`, + [ + { text: "Batal", style: "cancel" }, + { + text: "Hapus", + style: "destructive", + onPress: async () => { + try { + const response = await fetch(`${API_BASE}/arcface/delete/${user.nim}`, { + method: "DELETE", + headers: { "Authorization": `Bearer ${token}` }, + }); + const result = await response.json(); + if (result.success) { + setIsAlreadyRegistered(false); + Alert.alert("Berhasil", "Data wajah berhasil dihapus."); + } else { + Alert.alert("Gagal", result.message || "Gagal menghapus data wajah."); + } + } catch (error) { + Alert.alert("Error", "Terjadi kesalahan koneksi."); + } + }, + }, + ] + ); + }; + + // ── Handle Confirm / Submit ─────────────────────────────────────────────── + const handleConfirm = async () => { + if (capturedImages.length === 0) { + Alert.alert("Error", "Tidak ada foto yang akan dikirim."); + return; + } + + if (!user?.nim) { + Alert.alert("Error", "Silakan login terlebih dahulu untuk melakukan registrasi wajah."); + return; + } + + if (isAlreadyRegistered) { + Alert.alert( + "Perbarui Foto?", + `Wajah Anda sudah terdaftar. Foto lama akan ditimpa dengan foto baru.\n\nApakah ingin melanjutkan?`, + [ + { text: "Batal", style: "cancel" }, + { text: "Lanjutkan", onPress: () => performEnrollment(true) }, + ] + ); + } else { + performEnrollment(false); + } + }; + + const performEnrollment = async (forceUpdate: boolean) => { + setIsSubmitting(true); + + try { + const photosBase64: string[] = []; + for (const photoUri of capturedImages) { + const base64Response = await fetch(photoUri); + const blob = await base64Response.blob(); + const reader = new FileReader(); + const base64 = await new Promise((resolve, reject) => { + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("Failed to read image")); + reader.readAsDataURL(blob); + }); + photosBase64.push(base64); + } + + const response = await fetch(`${API_BASE}/arcface/enroll`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ + nim: user!.nim, + foto_base64: photosBase64, + force_update: forceUpdate, + }), + }); + + const result = await response.json(); + + if (result.success) { + setIsAlreadyRegistered(true); + Alert.alert( + forceUpdate ? "Berhasil Diperbarui! 🎉" : "Berhasil! 🎉", + forceUpdate + ? `Wajah ${user!.nama_mahasiswa || user!.name} (${user!.nim}) berhasil diperbarui!\n\nData lama telah ditimpa.` + : `Wajah ${user!.nama_mahasiswa || user!.name} (${user!.nim}) berhasil terdaftar!\n\nEmbedding tersimpan di: face_mahasiswa/${user!.nim}/`, + [{ text: "OK", onPress: handleRetake }] + ); + } else { + if (result.message?.includes("sudah ter-enroll")) { + if (forceUpdate) { + setIsAlreadyRegistered(true); + Alert.alert( + "Berhasil Diperbarui! 🎉", + `Wajah ${user!.nama_mahasiswa || user!.name} (${user!.nim}) berhasil diperbarui!\n\nData lama telah ditimpa.`, + [{ text: "OK", onPress: handleRetake }] + ); + } else { + Alert.alert( + "Info", + "Wajah Anda sudah terdaftar sebelumnya.\n\nJika ingin memperbarui foto, silakan ambil foto baru dan pilih 'Perbarui'.", + [{ text: "OK", onPress: handleRetake }] + ); + } + } else if (result.message?.includes("ArcFace model")) { + Alert.alert( + "Model Error", + "Sistem Face Recognition belum siap. Hubungi admin untuk mengaktifkan model ArcFace.", + [{ text: "OK" }] + ); + } else { + Alert.alert("Gagal", result.message || "Gagal menyimpan foto wajah."); + } + } + } catch (error) { + console.error("Enrollment error:", error); + Alert.alert("Error", "Gagal mengirim foto. Periksa koneksi internet dan coba lagi."); + } finally { + setIsSubmitting(false); + } + }; + + // ─── Render: Permission Loading ─────────────────────────────────────────── + + if (!permission) { + return ( + + + + + Memuat kamera... + + + ); + } + + if (!permission.granted) { + return ( + + + + + + + + Izinkan Akses Kamera + + Kami membutuhkan akses kamera untuk mengambil foto wajah Anda untuk verifikasi kehadiran. + + + + + Izinkan Kamera + + + + + + ); + } + + // ─── Render: Main Screen ───────────────────────────────────────────────── + + return ( + + + + {/* Header */} + + + + + + + + {isAlreadyRegistered ? "Update Wajah" : "Register Wajah"} + + + {isCheckingStatus + ? "Memuat..." + : isAlreadyRegistered + ? "✓ Sudah terdaftar" + : "Ambil foto untuk verifikasi"} + + + + + {/* Gallery Button - Select one by one with crop editor */} + { + if (allCaptured || showPreview) return; + + const remaining = totalRequired - capturedImages.length; + if (remaining <= 0) return; + + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: false, + quality: 1, + }); + + if (!result.canceled && result.assets.length > 0) { + const selectedPhoto = result.assets[0]; + setSelectedImageUri(selectedPhoto.uri); + setShowCropEditor(true); + } + } catch (err) { + Alert.alert("Error", "Gagal memilih gambar dari galeri"); + } + }} + > + + + {/* Delete Button */} + {isAlreadyRegistered && ( + + + + )} + + + + {/* Scrollable body */} + + {/* Pose guide — DI ATAS oval, tidak menghalangi wajah */} + {!showPreview && !allCaptured && ( + + + {currentPose.icon} + {currentPose.label} + {currentPose.description} + + + )} + + {/* Camera wrapper */} + + + {(showPreview || allCaptured) && capturedImages.length > 0 ? ( + /* Tampilkan foto depan (pertama) sebagai preview utama di oval */ + + ) : ( + <> + + {/* Flash overlay */} + + + )} + + + {!showPreview && !allCaptured && } + + {(showPreview || allCaptured) && ( + + + + {allCaptured ? `${capturedImages.length} foto selesai` : "Foto Tertangkap"} + + + )} + + + {/* Thumbnails — di bawah oval, tampilkan semua foto kecil-kecil */} + {capturedImages.length > 0 && (showPreview || allCaptured) && ( + + {capturedImages.map((uri, idx) => ( + + + + {idx < 2 ? 'Depan' : idx < 4 ? 'Kanan' : 'Kiri'} {(idx % 2) + 1} + + + ))} + + )} + + {/* Pose progress stepper */} + + {POSES.map((pose, idx) => { + const isDone = idx < currentPoseIndex || (idx === currentPoseIndex && allCaptured); + const isCurrent = idx === currentPoseIndex && !allCaptured; + return ( + + + {isDone && } + + {pose.label.replace('Hadap ', '')} + {idx < POSES.length - 1 && ( + + )} + + ); + })} + + + {/* Status badge */} + + + + + {showPreview + ? `${capturedImages.length} foto siap dikirim` + : allCaptured + ? "Semua pose selesai! Ketuk untuk simpan" + : isCapturing + ? "📸 Mengambil 2 foto..." + : `${currentPose.label} — Ketuk tombol untuk ambil foto`} + + + + + {/* Instructions */} + + + + + {showPreview || allCaptured + ? "6 foto siap dikirim ke server" + : "Tekan tombol kamera atau galeri untuk ambil foto"} + + + + + + Tombol galeri: pilih foto satu per satu, di-crop otomatis sesuai oval + + + + + + {allCaptured ? 'Semua 3 pose sudah diambil' : `Langkah ${currentPoseIndex + 1}/3: ${currentPose.description}`} + + + + + Lepas kacamata, topi, atau masker + + + + + {/* Bottom Action — fixed */} + + {showPreview ? ( + + + + Ulangi + + + + + + {isSubmitting ? ( + <> + + Mengirim... + + ) : ( + <> + + Simpan {capturedImages.length} Foto + + )} + + + + + ) : ( + + + setShowPreview(true) : handleBurstCapture} + disabled={isCapturing} + activeOpacity={0.85} + > + + {isCapturing ? ( + + ) : allCaptured ? ( + + ) : ( + + )} + + + + + + {isCapturing + ? "Mengambil 2 foto..." + : allCaptured + ? `${capturedImages.length} foto — Ketuk untuk preview` + : `Ketuk untuk ambil foto ${currentPose.label}`} + + + )} + + + {/* Crop Editor Modal */} + { + setShowCropEditor(false); + setCapturedImages(prev => [...prev, croppedUri]); + + const newCount = capturedImages.length + 1; + const nextPose = Math.floor(newCount / CAPTURES_PER_POSE); + + // Update pose index + if (newCount % CAPTURES_PER_POSE === 0 && nextPose < POSES.length) { + setCurrentPoseIndex(Math.min(nextPose, POSES.length - 1)); + } + + // If all 6 photos, go to preview + if (newCount >= totalRequired) { + setShowPreview(true); + setCurrentPoseIndex(POSES.length - 1); + } + }} + onCancel={() => { + setShowCropEditor(false); + setSelectedImageUri(''); + }} + /> + + ); +} + +// ─── Styles ───────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: THEME.bg, + }, + + // ─── Header ─────────────────────────────────────────────────────────────── + + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingTop: Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) + 10 : 55, + paddingHorizontal: 20, + paddingBottom: 14, + backgroundColor: THEME.bg, + borderBottomWidth: 1, + borderBottomColor: THEME.border, + }, + headerLeft: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + headerIconContainer: { + width: 42, + height: 42, + borderRadius: 12, + backgroundColor: THEME.primaryBg, + alignItems: "center", + justifyContent: "center", + }, + headerTitle: { + fontSize: 18, + fontWeight: "800", + color: THEME.textPrimary, + letterSpacing: 0.2, + }, + headerSubtitle: { + fontSize: 12, + color: THEME.primary, + marginTop: 1, + }, + headerRightButtons: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + iconButton: { + width: 42, + height: 42, + borderRadius: 12, + backgroundColor: THEME.bgInput, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: THEME.border, + }, + deleteButton: { + borderColor: `${THEME.danger}40`, + backgroundColor: `${THEME.danger}10`, + }, + + // ─── Scroll body ────────────────────────────────────────────────────────── + + scrollView: { + flex: 1, + }, + scrollContent: { + alignItems: "center", + paddingTop: 16, + paddingBottom: 160, + }, + + // ─── Camera area ────────────────────────────────────────────────────────── + + cameraWrapper: { + alignSelf: "center", + width: CAMERA_SIZE, + height: CAMERA_SIZE, + position: "relative", + borderRadius: 24, + backgroundColor: "#FFFFFF", + }, + ovalClip: { + position: "absolute", + top: OVAL_TOP, + left: OVAL_LEFT, + width: OVAL_WIDTH, + height: OVAL_HEIGHT, + borderRadius: OVAL_WIDTH / 2, + overflow: "hidden", + backgroundColor: THEME.bgSecondary, + }, + cameraInner: { + position: "absolute", + top: -OVAL_TOP, + left: -OVAL_LEFT, + width: CAMERA_SIZE, + height: CAMERA_SIZE, + }, + previewImage: { + width: "100%", + height: "100%", + }, + ovalRing: { + position: "absolute", + top: OVAL_TOP, + left: OVAL_LEFT, + width: OVAL_WIDTH, + height: OVAL_HEIGHT, + borderRadius: OVAL_WIDTH / 2, + borderWidth: 3, + borderColor: "#FFFFFF", + zIndex: 10, + }, + flashOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "#FFFFFF", + zIndex: 12, + }, + + // ─── Pose guide (above oval) ──────────────────────────────────────────────── + + poseGuideAbove: { + alignItems: "center", + marginBottom: 12, + }, + poseGuideBox: { + backgroundColor: "rgba(0,0,0,0.82)", + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 16, + alignItems: "center", + borderWidth: 2, + gap: 3, + }, + poseIcon: { + fontSize: 26, + }, + poseLabel: { + fontSize: 16, + fontWeight: "700", + color: "#FFFFFF", + }, + poseDesc: { + fontSize: 12, + color: "rgba(255,255,255,0.75)", + }, + + // ─── Preview badge ──────────────────────────────────────────────────────── + + previewBadge: { + position: "absolute", + top: 12, + right: 12, + flexDirection: "row", + alignItems: "center", + gap: 6, + backgroundColor: "rgba(255,255,255,0.95)", + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 20, + borderWidth: 1, + borderColor: THEME.border, + zIndex: 20, + }, + previewBadgeText: { + fontSize: 11, + fontWeight: "600", + color: THEME.success, + }, + + // ─── Thumbnail row (below oval) ─────────────────────────────────────────── + + thumbnailRow: { + flexDirection: "row", + justifyContent: "center", + flexWrap: "wrap", + gap: 10, + marginTop: 14, + paddingHorizontal: 20, + }, + thumbnailWrapper: { + alignItems: "center", + gap: 4, + }, + thumbnailBelow: { + width: 52, + height: 64, + borderRadius: 10, + borderWidth: 2, + borderColor: THEME.border, + }, + thumbnailLabel: { + fontSize: 9, + fontWeight: "600", + color: THEME.textMuted, + }, + + // ─── Stepper ────────────────────────────────────────────────────────────── + + stepperContainer: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + marginTop: 16, + paddingHorizontal: 16, + }, + stepperItem: { + alignItems: "center", + flexDirection: "row", + gap: 4, + }, + stepperDot: { + width: 22, + height: 22, + borderRadius: 11, + alignItems: "center", + justifyContent: "center", + }, + stepperLabel: { + fontSize: 10, + color: THEME.textMuted, + fontWeight: "500", + marginRight: 2, + }, + stepperLine: { + width: 16, + height: 2, + backgroundColor: THEME.border, + marginHorizontal: 2, + borderRadius: 1, + }, + + // ─── Status badge ───────────────────────────────────────────────────────── + + statusWrapper: { + alignItems: "center", + marginTop: 12, + paddingHorizontal: 24, + }, + statusBadge: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 18, + paddingVertical: 9, + borderRadius: 50, + borderWidth: 1, + gap: 8, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + statusText: { + fontSize: 13, + fontWeight: "600", + }, + + // ─── Info panel ─────────────────────────────────────────────────────────── + + infoPanel: { + marginHorizontal: 20, + marginTop: 14, + width: CAMERA_SIZE, + backgroundColor: THEME.bgCard, + borderRadius: 16, + padding: 14, + borderWidth: 1, + borderColor: THEME.border, + gap: 10, + }, + infoPanelRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + infoPanelText: { + fontSize: 13, + color: THEME.textSecondary, + flex: 1, + lineHeight: 18, + }, + + // ─── Bottom capture section ─────────────────────────────────────────────── + + captureSection: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + alignItems: "center", + paddingTop: 16, + paddingBottom: Platform.OS === "android" ? 20 : 32, + backgroundColor: THEME.bg, + borderTopWidth: 1, + borderTopColor: THEME.border, + }, + captureContainer: { + alignItems: "center", + gap: 10, + }, + captureButtonOuter: { + alignItems: "center", + justifyContent: "center", + }, + captureButton: { + width: 72, + height: 72, + borderRadius: 36, + alignItems: "center", + justifyContent: "center", + shadowColor: THEME.primary, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, + shadowRadius: 16, + elevation: 8, + }, + captureRing: { + position: "absolute", + width: 86, + height: 86, + borderRadius: 43, + borderWidth: 2.5, + opacity: 0.25, + }, + captureHintBottom: { + fontSize: 13, + color: THEME.textSecondary, + fontWeight: "500", + }, + + // ─── Preview actions ────────────────────────────────────────────────────── + + previewActions: { + flexDirection: "row", + alignItems: "center", + gap: 16, + }, + retakeButton: { + alignItems: "center", + justifyContent: "center", + gap: 4, + width: 68, + height: 68, + borderRadius: 34, + backgroundColor: THEME.bgInput, + borderWidth: 2, + borderColor: THEME.border, + }, + retakeButtonText: { + fontSize: 11, + fontWeight: "600", + color: THEME.primary, + }, + confirmButton: { + borderRadius: 40, + overflow: "hidden", + shadowColor: THEME.success, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.35, + shadowRadius: 16, + elevation: 8, + }, + confirmButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + paddingHorizontal: 28, + paddingVertical: 18, + borderRadius: 40, + }, + confirmButtonText: { + fontSize: 15, + fontWeight: "700", + color: "#fff", + }, + + // ─── Permission screen ──────────────────────────────────────────────────── + + permissionContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + permissionCard: { + alignItems: "center", + backgroundColor: THEME.bgCard, + borderRadius: 24, + padding: 32, + borderWidth: 1, + borderColor: THEME.border, + width: "100%", + maxWidth: 340, + shadowColor: THEME.shadowDark, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 1, + shadowRadius: 16, + elevation: 4, + }, + permissionIconBg: { + width: 96, + height: 96, + borderRadius: 48, + backgroundColor: THEME.primaryBg, + alignItems: "center", + justifyContent: "center", + marginBottom: 20, + }, + permissionTitle: { + fontSize: 20, + fontWeight: "700", + color: THEME.textPrimary, + marginBottom: 12, + textAlign: "center", + }, + permissionDesc: { + fontSize: 14, + color: THEME.textSecondary, + textAlign: "center", + lineHeight: 22, + marginBottom: 28, + paddingHorizontal: 8, + }, + permissionButton: { + borderRadius: 14, + overflow: "hidden", + width: "100%", + }, + permissionButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 10, + paddingVertical: 16, + borderRadius: 14, + }, + permissionButtonText: { + color: "#fff", + fontSize: 16, + fontWeight: "700", + }, + + // ─── Loading ─────────────────────────────────────────────────────────────── + + loadingContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 16, + }, + loadingText: { + fontSize: 14, + color: THEME.textSecondary, + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/app/_layout.tsx b/facerecognition_presensi/AndroidFolder/app/_layout.tsx new file mode 100644 index 0000000..242f8ec --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/_layout.tsx @@ -0,0 +1,80 @@ +import { + DarkTheme, + DefaultTheme, + ThemeProvider, +} from "@react-navigation/native"; +import { Stack, useRouter, useSegments } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; +import { View } from "react-native"; +import "react-native-reanimated"; + +import { useColorScheme } from "@/hooks/use-color-scheme"; +import { AuthProvider, useAuth } from "@/contexts/AuthContext"; +import { ThemeProvider as AppThemeProvider } from "@/contexts/ThemeContext"; + +export const unstable_settings = { + initialRouteName: "login", +}; + +// ── Redirect ke /login jika tidak punya token ────────────────────────────── +function AuthGuard() { + const { token, isLoading } = useAuth(); + const router = useRouter(); + const segments = useSegments(); + + useEffect(() => { + // Jangan redirect saat masih loading (hydrate dari AsyncStorage) + if (isLoading) return; + + const inTabs = segments[0] === "(tabs)"; + const inLogin = segments[0] === "login"; + + if (!token && inTabs) { + router.replace("/login"); + } else if (token && inLogin) { + router.replace("/(tabs)"); + } + }, [token, isLoading, segments, router]); + + return null; +} + +// ── Setiap sentuhan di mana saja akan mereset timer idle ─────────────────── +function ActivityWrapper({ children }: { children: React.ReactNode }) { + const { updateActivity } = useAuth(); + return ( + + {children} + + ); +} + +export default function RootLayout() { + const colorScheme = useColorScheme(); + + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/facerecognition_presensi/AndroidFolder/app/ganti_password.tsx b/facerecognition_presensi/AndroidFolder/app/ganti_password.tsx new file mode 100644 index 0000000..64ccafe --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/ganti_password.tsx @@ -0,0 +1,354 @@ +import { IconSymbol } from '@/components/ui/icon-symbol'; +import { StatusBar } from 'expo-status-bar'; +import { useMemo, useState } from 'react'; +import { + Dimensions, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View +} from 'react-native'; + +const { width } = Dimensions.get('window'); + +export default function ChangePasswordScreen() { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const [showCurrent, setShowCurrent] = useState(false); + const [showNew, setShowNew] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + // Validasi sederhana kekuatan password + const passwordChecks = useMemo(() => { + return { + length: newPassword.length >= 8, + upper: /[A-Z]/.test(newPassword), + number: /[0-9]/.test(newPassword), + match: newPassword.length > 0 && newPassword === confirmPassword, + }; + }, [newPassword, confirmPassword]); + + const isFormValid = + currentPassword.length > 0 && + passwordChecks.length && + passwordChecks.upper && + passwordChecks.number && + passwordChecks.match; + + const handleChangePassword = () => { + if (!isFormValid) return; + // Handle change password logic + console.log('Change password pressed'); + }; + + return ( + + + + {/* Header */} + + + + + Buat Password Baru + + Ini adalah login pertama Anda.{'\n'}Silakan ganti password default dari admin. + + + + {/* Form Card */} + + {/* Current / Default Password */} + + PASSWORD SAAT INI (DEFAULT) + + + + setShowCurrent(!showCurrent)} + style={styles.eyeIcon} + > + + + + + + {/* New Password */} + + PASSWORD BARU + + + + setShowNew(!showNew)} + style={styles.eyeIcon} + > + + + + + + {/* Confirm New Password */} + + KONFIRMASI PASSWORD BARU + + + + setShowConfirm(!showConfirm)} + style={styles.eyeIcon} + > + + + + + + {/* Password Requirements Checklist */} + + + + + + + + {/* Submit Button */} + + Simpan & Lanjutkan + + + {/* Info note */} + + + + Anda tidak dapat melanjutkan ke aplikasi sebelum mengganti password default ini. + + + + + + ); +} + +function ChecklistItem({ label, checked }: { label: string; checked: boolean }) { + return ( + + + {checked && } + + {label} + + ); +} + +const itemStyles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + dot: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 1.5, + borderColor: '#CBD5E1', + marginRight: 8, + justifyContent: 'center', + alignItems: 'center', + }, + dotActive: { + backgroundColor: '#3B82F6', + borderColor: '#3B82F6', + }, + label: { + fontSize: 12.5, + color: '#94A3B8', + }, + labelActive: { + color: '#1E3A8A', + fontWeight: '600', + }, +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#EFF6FF', + }, + scrollContent: { + flexGrow: 1, + paddingBottom: 40, + }, + headerContainer: { + alignItems: 'center', + paddingTop: 70, + paddingBottom: 32, + paddingHorizontal: 30, + }, + logoCircle: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: '#fff', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 20, + shadowColor: '#3B82F6', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 4, + }, + welcomeText: { + fontSize: 24, + fontWeight: '700', + color: '#1E3A8A', + marginBottom: 8, + textAlign: 'center', + }, + subtitleText: { + fontSize: 13.5, + color: '#60A5FA', + fontWeight: '400', + textAlign: 'center', + lineHeight: 20, + }, + formCard: { + backgroundColor: '#fff', + marginHorizontal: 24, + borderRadius: 24, + padding: 28, + shadowColor: '#3B82F6', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.08, + shadowRadius: 20, + elevation: 3, + }, + inputWrapper: { + marginBottom: 18, + }, + inputLabel: { + fontSize: 11, + color: '#60A5FA', + fontWeight: '700', + marginBottom: 8, + letterSpacing: 0.5, + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1.5, + borderColor: '#DBEAFE', + borderRadius: 14, + backgroundColor: '#F8FAFF', + paddingHorizontal: 14, + height: 52, + }, + inputIcon: { + marginRight: 10, + }, + input: { + flex: 1, + fontSize: 15, + color: '#1E293B', + height: '100%', + }, + eyeIcon: { + padding: 4, + }, + checklistContainer: { + backgroundColor: '#F8FAFF', + borderRadius: 14, + padding: 16, + marginTop: 4, + marginBottom: 24, + }, + submitButton: { + backgroundColor: '#3B82F6', + height: 54, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#3B82F6', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.35, + shadowRadius: 12, + elevation: 5, + marginBottom: 20, + }, + submitButtonDisabled: { + backgroundColor: '#BFDBFE', + shadowOpacity: 0, + elevation: 0, + }, + submitButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + letterSpacing: 0.3, + }, + infoBox: { + flexDirection: 'row', + alignItems: 'flex-start', + backgroundColor: '#EFF6FF', + borderRadius: 12, + padding: 12, + gap: 8, + }, + infoText: { + flex: 1, + fontSize: 12, + color: '#3B82F6', + lineHeight: 17, + }, +}); \ No newline at end of file diff --git a/facerecognition_presensi/AndroidFolder/app/login.tsx b/facerecognition_presensi/AndroidFolder/app/login.tsx new file mode 100644 index 0000000..d4af281 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/login.tsx @@ -0,0 +1,467 @@ +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { StatusBar } from "expo-status-bar"; +import { useState, useEffect } from "react"; +import { + ActivityIndicator, + Dimensions, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { router } from "expo-router"; +import { useAuth } from "@/contexts/AuthContext"; +import * as SecureStore from "expo-secure-store"; + +const { width } = Dimensions.get("window"); + +// Secure storage keys for remembered credentials +const SECURE_KEYS = { + EMAIL: "remembered_email", + PASSWORD: "remembered_password", + REMEMBER: "remember_remember", +} as const; + +export default function LoginScreen() { + const { login } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [remember, setRemember] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isHydrated, setIsHydrated] = useState(false); + + // Load saved credentials on mount + useEffect(() => { + const loadSavedCredentials = async () => { + try { + const [savedEmail, savedPassword, savedRemember] = await Promise.all([ + SecureStore.getItemAsync(SECURE_KEYS.EMAIL), + SecureStore.getItemAsync(SECURE_KEYS.PASSWORD), + SecureStore.getItemAsync(SECURE_KEYS.REMEMBER), + ]); + + if (savedEmail) setEmail(savedEmail); + if (savedPassword) setPassword(savedPassword); + if (savedRemember === "true") { + setRemember(true); + } + } catch (e) { + console.error("Failed to load saved credentials:", e); + } finally { + setIsHydrated(true); + } + }; + + loadSavedCredentials(); + }, []); + + // Save credentials when remember is toggled or on successful login + const saveCredentials = async () => { + try { + if (remember) { + await Promise.all([ + SecureStore.setItemAsync(SECURE_KEYS.EMAIL, email), + SecureStore.setItemAsync(SECURE_KEYS.PASSWORD, password), + SecureStore.setItemAsync(SECURE_KEYS.REMEMBER, "true"), + ]); + } else { + await Promise.all([ + SecureStore.deleteItemAsync(SECURE_KEYS.EMAIL), + SecureStore.deleteItemAsync(SECURE_KEYS.PASSWORD), + SecureStore.deleteItemAsync(SECURE_KEYS.REMEMBER), + ]); + } + } catch (e) { + console.error("Failed to save credentials:", e); + } + }; + + const handleLogin = async () => { + if (!email || !password) { + setError("Email dan password wajib diisi"); + return; + } + setError(null); + setIsLoading(true); + try { + await login(email, password); + // Save credentials if "remember me" is checked + await saveCredentials(); + router.replace("/(tabs)"); + } catch (e) { + setError(e instanceof Error ? e.message : "Terjadi kesalahan"); + } finally { + setIsLoading(false); + } + }; + + // Don't render form until credentials are loaded from secure storage + if (!isHydrated) { + return ( + + + + ); + }; + + return ( + + + + {/* Header */} + + + + + Selamat Datang + Masuk untuk melanjutkan + + + {/* Form Card */} + + {/* Error Banner */} + {error && ( + + {error} + + )} + {/* Email Input */} + + EMAIL + + + + + + + {/* Password Input */} + + PASSWORD + + + + setShowPassword(!showPassword)} + style={styles.eyeIcon} + > + + + + + + {/* Remember me + Forgot password */} + + setRemember(!remember)} + > + + {remember && ( + + )} + + Ingat saya + + + + Lupa password? + + + + {/* Login Button */} + + {isLoading ? ( + + ) : ( + Masuk + )} + + + {/* Register New Button */} + {/* router.push("/register-foto")} + activeOpacity={0.8} + > + + + Register Wajah Baru + + + */} + + + + ); +} + +const styles = StyleSheet.create({ + loadingContainer: { + flex: 1, + backgroundColor: "#EFF6FF", + justifyContent: "center", + alignItems: "center", + }, + container: { + flex: 1, + backgroundColor: "#EFF6FF", // biru sangat muda + }, + scrollContent: { + flexGrow: 1, + paddingBottom: 40, + }, + headerContainer: { + alignItems: "center", + paddingTop: 80, + paddingBottom: 40, + paddingHorizontal: 30, + }, + logoCircle: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: "#fff", + justifyContent: "center", + alignItems: "center", + marginBottom: 20, + shadowColor: "#3B82F6", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 4, + }, + welcomeText: { + fontSize: 26, + fontWeight: "700", + color: "#1E3A8A", + marginBottom: 6, + }, + subtitleText: { + fontSize: 14, + color: "#60A5FA", + fontWeight: "400", + }, + formCard: { + backgroundColor: "#fff", + marginHorizontal: 24, + borderRadius: 24, + padding: 28, + shadowColor: "#3B82F6", + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.08, + shadowRadius: 20, + elevation: 3, + }, + inputWrapper: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 11, + color: "#60A5FA", + fontWeight: "700", + marginBottom: 8, + letterSpacing: 0.5, + }, + inputContainer: { + flexDirection: "row", + alignItems: "center", + borderWidth: 1.5, + borderColor: "#DBEAFE", + borderRadius: 14, + backgroundColor: "#F8FAFF", + paddingHorizontal: 14, + height: 52, + }, + inputIcon: { + marginRight: 10, + }, + input: { + flex: 1, + fontSize: 15, + color: "#1E293B", + height: "100%", + }, + eyeIcon: { + padding: 4, + }, + optionsRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 24, + }, + rememberContainer: { + flexDirection: "row", + alignItems: "center", + }, + checkbox: { + width: 18, + height: 18, + borderRadius: 5, + borderWidth: 1.5, + borderColor: "#93C5FD", + marginRight: 8, + justifyContent: "center", + alignItems: "center", + }, + checkboxActive: { + backgroundColor: "#3B82F6", + borderColor: "#3B82F6", + }, + rememberText: { + fontSize: 13, + color: "#475569", + }, + forgotText: { + fontSize: 13, + color: "#3B82F6", + fontWeight: "600", + }, + loginButton: { + backgroundColor: "#3B82F6", + height: 54, + borderRadius: 16, + justifyContent: "center", + alignItems: "center", + shadowColor: "#3B82F6", + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.35, + shadowRadius: 12, + elevation: 5, + marginBottom: 16, + }, + loginButtonText: { + color: "#fff", + fontSize: 16, + fontWeight: "700", + letterSpacing: 0.3, + }, + registerButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + backgroundColor: "#EFF6FF", + borderWidth: 1.5, + borderColor: "#DBEAFE", + borderRadius: 14, + paddingVertical: 14, + paddingHorizontal: 18, + }, + registerButtonContent: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + registerButtonText: { + fontSize: 15, + fontWeight: "600", + color: "#3B82F6", + }, + errorBanner: { + backgroundColor: "#FEF2F2", + borderWidth: 1, + borderColor: "#FECACA", + borderRadius: 10, + padding: 12, + marginBottom: 16, + }, + errorText: { + color: "#DC2626", + fontSize: 13, + fontWeight: "500", + textAlign: "center", + }, + dividerContainer: { + flexDirection: "row", + alignItems: "center", + marginBottom: 20, + }, + dividerLine: { + flex: 1, + height: 1, + backgroundColor: "#DBEAFE", + }, + dividerText: { + fontSize: 12, + color: "#93C5FD", + marginHorizontal: 12, + }, + socialContainer: { + flexDirection: "row", + justifyContent: "center", + gap: 16, + marginBottom: 24, + }, + socialButton: { + width: 48, + height: 48, + borderRadius: 14, + backgroundColor: "#F0F6FF", + borderWidth: 1, + borderColor: "#DBEAFE", + justifyContent: "center", + alignItems: "center", + }, + signUpContainer: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + }, + signUpText: { + fontSize: 14, + color: "#64748B", + }, + signUpLink: { + fontSize: 14, + color: "#3B82F6", + fontWeight: "700", + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/app/modal.tsx b/facerecognition_presensi/AndroidFolder/app/modal.tsx new file mode 100644 index 0000000..6dfbc1a --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/app/modal.tsx @@ -0,0 +1,29 @@ +import { Link } from 'expo-router'; +import { StyleSheet } from 'react-native'; + +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; + +export default function ModalScreen() { + return ( + + This is a modal + + Go to home screen + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + link: { + marginTop: 15, + paddingVertical: 15, + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/assets/images/android-icon-background.png b/facerecognition_presensi/AndroidFolder/assets/images/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/android-icon-background.png differ diff --git a/facerecognition_presensi/AndroidFolder/assets/images/android-icon-foreground.png b/facerecognition_presensi/AndroidFolder/assets/images/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/android-icon-foreground.png differ diff --git a/facerecognition_presensi/AndroidFolder/assets/images/android-icon-monochrome.png b/facerecognition_presensi/AndroidFolder/assets/images/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/android-icon-monochrome.png differ diff --git a/facerecognition_presensi/AndroidFolder/assets/images/favicon.png b/facerecognition_presensi/AndroidFolder/assets/images/favicon.png new file mode 100644 index 0000000..408bd74 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/favicon.png differ diff --git a/facerecognition_presensi/AndroidFolder/assets/images/icon.png b/facerecognition_presensi/AndroidFolder/assets/images/icon.png new file mode 100644 index 0000000..7165a53 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/icon.png differ diff --git a/facerecognition_presensi/AndroidFolder/assets/images/partial-react-logo.png b/facerecognition_presensi/AndroidFolder/assets/images/partial-react-logo.png new file mode 100644 index 0000000..66fd957 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/partial-react-logo.png differ diff --git a/facerecognition_presensi/AndroidFolder/assets/images/react-logo.png b/facerecognition_presensi/AndroidFolder/assets/images/react-logo.png new file mode 100644 index 0000000..9d72a9f Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/react-logo.png differ diff --git a/facerecognition_presensi/AndroidFolder/assets/images/react-logo@2x.png b/facerecognition_presensi/AndroidFolder/assets/images/react-logo@2x.png new file mode 100644 index 0000000..2229b13 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/react-logo@2x.png differ diff --git a/facerecognition_presensi/AndroidFolder/assets/images/react-logo@3x.png b/facerecognition_presensi/AndroidFolder/assets/images/react-logo@3x.png new file mode 100644 index 0000000..a99b203 Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/react-logo@3x.png differ diff --git a/facerecognition_presensi/AndroidFolder/assets/images/splash-icon.png b/facerecognition_presensi/AndroidFolder/assets/images/splash-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/facerecognition_presensi/AndroidFolder/assets/images/splash-icon.png differ diff --git a/facerecognition_presensi/AndroidFolder/components/external-link.tsx b/facerecognition_presensi/AndroidFolder/components/external-link.tsx new file mode 100644 index 0000000..883e515 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/components/external-link.tsx @@ -0,0 +1,25 @@ +import { Href, Link } from 'expo-router'; +import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; +import { type ComponentProps } from 'react'; + +type Props = Omit, 'href'> & { href: Href & string }; + +export function ExternalLink({ href, ...rest }: Props) { + return ( + { + if (process.env.EXPO_OS !== 'web') { + // Prevent the default behavior of linking to the default browser on native. + event.preventDefault(); + // Open the link in an in-app browser. + await openBrowserAsync(href, { + presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, + }); + } + }} + /> + ); +} diff --git a/facerecognition_presensi/AndroidFolder/components/haptic-tab.tsx b/facerecognition_presensi/AndroidFolder/components/haptic-tab.tsx new file mode 100644 index 0000000..7f3981c --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/components/haptic-tab.tsx @@ -0,0 +1,18 @@ +import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; +import { PlatformPressable } from '@react-navigation/elements'; +import * as Haptics from 'expo-haptics'; + +export function HapticTab(props: BottomTabBarButtonProps) { + return ( + { + if (process.env.EXPO_OS === 'ios') { + // Add a soft haptic feedback when pressing down on the tabs. + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + props.onPressIn?.(ev); + }} + /> + ); +} diff --git a/facerecognition_presensi/AndroidFolder/components/hello-wave.tsx b/facerecognition_presensi/AndroidFolder/components/hello-wave.tsx new file mode 100644 index 0000000..5def547 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/components/hello-wave.tsx @@ -0,0 +1,19 @@ +import Animated from 'react-native-reanimated'; + +export function HelloWave() { + return ( + + 👋 + + ); +} diff --git a/facerecognition_presensi/AndroidFolder/components/parallax-scroll-view.tsx b/facerecognition_presensi/AndroidFolder/components/parallax-scroll-view.tsx new file mode 100644 index 0000000..6f674a7 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/components/parallax-scroll-view.tsx @@ -0,0 +1,79 @@ +import type { PropsWithChildren, ReactElement } from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { + interpolate, + useAnimatedRef, + useAnimatedStyle, + useScrollOffset, +} from 'react-native-reanimated'; + +import { ThemedView } from '@/components/themed-view'; +import { useColorScheme } from '@/hooks/use-color-scheme'; +import { useThemeColor } from '@/hooks/use-theme-color'; + +const HEADER_HEIGHT = 250; + +type Props = PropsWithChildren<{ + headerImage: ReactElement; + headerBackgroundColor: { dark: string; light: string }; +}>; + +export default function ParallaxScrollView({ + children, + headerImage, + headerBackgroundColor, +}: Props) { + const backgroundColor = useThemeColor({}, 'background'); + const colorScheme = useColorScheme() ?? 'light'; + const scrollRef = useAnimatedRef(); + const scrollOffset = useScrollOffset(scrollRef); + const headerAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: interpolate( + scrollOffset.value, + [-HEADER_HEIGHT, 0, HEADER_HEIGHT], + [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] + ), + }, + { + scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), + }, + ], + }; + }); + + return ( + + + {headerImage} + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + height: HEADER_HEIGHT, + overflow: 'hidden', + }, + content: { + flex: 1, + padding: 32, + gap: 16, + overflow: 'hidden', + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/components/themed-text.tsx b/facerecognition_presensi/AndroidFolder/components/themed-text.tsx new file mode 100644 index 0000000..d79d0a1 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/components/themed-text.tsx @@ -0,0 +1,60 @@ +import { StyleSheet, Text, type TextProps } from 'react-native'; + +import { useThemeColor } from '@/hooks/use-theme-color'; + +export type ThemedTextProps = TextProps & { + lightColor?: string; + darkColor?: string; + type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; +}; + +export function ThemedText({ + style, + lightColor, + darkColor, + type = 'default', + ...rest +}: ThemedTextProps) { + const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); + + return ( + + ); +} + +const styles = StyleSheet.create({ + default: { + fontSize: 16, + lineHeight: 24, + }, + defaultSemiBold: { + fontSize: 16, + lineHeight: 24, + fontWeight: '600', + }, + title: { + fontSize: 32, + fontWeight: 'bold', + lineHeight: 32, + }, + subtitle: { + fontSize: 20, + fontWeight: 'bold', + }, + link: { + lineHeight: 30, + fontSize: 16, + color: '#0a7ea4', + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/components/themed-view.tsx b/facerecognition_presensi/AndroidFolder/components/themed-view.tsx new file mode 100644 index 0000000..6f181d8 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/components/themed-view.tsx @@ -0,0 +1,14 @@ +import { View, type ViewProps } from 'react-native'; + +import { useThemeColor } from '@/hooks/use-theme-color'; + +export type ThemedViewProps = ViewProps & { + lightColor?: string; + darkColor?: string; +}; + +export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { + const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); + + return ; +} diff --git a/facerecognition_presensi/AndroidFolder/components/ui/collapsible.tsx b/facerecognition_presensi/AndroidFolder/components/ui/collapsible.tsx new file mode 100644 index 0000000..6345fde --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/components/ui/collapsible.tsx @@ -0,0 +1,45 @@ +import { PropsWithChildren, useState } from 'react'; +import { StyleSheet, TouchableOpacity } from 'react-native'; + +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import { Colors } from '@/constants/theme'; +import { useColorScheme } from '@/hooks/use-color-scheme'; + +export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { + const [isOpen, setIsOpen] = useState(false); + const theme = useColorScheme() ?? 'light'; + + return ( + + setIsOpen((value) => !value)} + activeOpacity={0.8}> + + + {title} + + {isOpen && {children}} + + ); +} + +const styles = StyleSheet.create({ + heading: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + content: { + marginTop: 6, + marginLeft: 24, + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/components/ui/icon-symbol.ios.tsx b/facerecognition_presensi/AndroidFolder/components/ui/icon-symbol.ios.tsx new file mode 100644 index 0000000..9177f4d --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/components/ui/icon-symbol.ios.tsx @@ -0,0 +1,32 @@ +import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols'; +import { StyleProp, ViewStyle } from 'react-native'; + +export function IconSymbol({ + name, + size = 24, + color, + style, + weight = 'regular', +}: { + name: SymbolViewProps['name']; + size?: number; + color: string; + style?: StyleProp; + weight?: SymbolWeight; +}) { + return ( + + ); +} diff --git a/facerecognition_presensi/AndroidFolder/components/ui/icon-symbol.tsx b/facerecognition_presensi/AndroidFolder/components/ui/icon-symbol.tsx new file mode 100644 index 0000000..1997b9a --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/components/ui/icon-symbol.tsx @@ -0,0 +1,78 @@ +// Fallback for using MaterialIcons on Android and web. + +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { SymbolWeight, SymbolViewProps } from 'expo-symbols'; +import { ComponentProps } from 'react'; +import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; + +type IconMapping = Record['name']>; +type IconSymbolName = keyof typeof MAPPING; + +/** + * Add your SF Symbols to Material Icons mappings here. + * - see Material Icons in the [Icons Directory](https://icons.expo.fyi). + * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app. + */ +const MAPPING = { + // Existing mappings + 'house.fill': 'home', + 'paperplane.fill': 'send', + 'chevron.left.forwardslash.chevron.right': 'code', + 'chevron.right': 'chevron-right', + 'chevron.left': 'chevron-left', + + // Custom mappings for Profile & App consistency on Android + 'person.fill': 'person', + 'checkmark.circle.fill': 'check-circle', + 'xmark.circle.fill': 'cancel', + 'exclamationmark.circle.fill': 'error', + 'graduationcap.fill': 'school', + 'arrow.right.square': 'logout', + 'lock.fill': 'lock', + 'bell.fill': 'notifications', + 'gearshape.fill': 'settings', + 'info.circle.fill': 'info', + 'checkmark': 'check', + 'xmark': 'close', + 'person.fill.checkmark': 'how-to-reg', + 'book.fill': 'book', + 'cross.case.fill': 'medical-services', + 'doc.text.fill': 'description', + 'mappin.and.ellipse': 'location-on', + 'chart.bar.fill': 'bar-chart', + 'qrcode': 'qr-code', + 'faceid': 'face', + 'doc.text': 'description', + 'clock.arrow.circlepath': 'history', + 'envelope': 'mail', + 'lock': 'lock-outline', + 'eye': 'visibility', + 'eye.slash': 'visibility-off', + 'key.fill': 'vpn-key', + 'lock.rotation': 'lock-reset', + 'checkmark.shield': 'verified-user', + 'info.circle': 'info-outline', + 'calendar': 'calendar-today', + 'person.badge.plus': 'person-add', + 'person.crop.circle.badge.plus': 'person-add', +} as IconMapping; + +/** + * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web. + * This ensures a consistent look across platforms, and optimal resource usage. + * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons. + */ +export function IconSymbol({ + name, + size = 24, + color, + style, +}: { + name: IconSymbolName; + size?: number; + color: string | OpaqueColorValue; + style?: StyleProp; + weight?: SymbolWeight; +}) { + return ; +} diff --git a/facerecognition_presensi/AndroidFolder/constants/config.ts b/facerecognition_presensi/AndroidFolder/constants/config.ts new file mode 100644 index 0000000..f97d7b5 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/constants/config.ts @@ -0,0 +1,5 @@ +// Emulator Android: 10.0.2.2; perangkat fisik: IP LAN komputer backend. +// EXPO_PUBLIC_* terlihat di aplikasi; jangan isi dengan rahasia. +export const BASE_URL = (process.env.EXPO_PUBLIC_API_URL || "http://10.0.2.2:8000").replace(/\/$/, ""); +export const API_BASE = `${BASE_URL}/api/v1`; +export const IDLE_TIMEOUT_MS = 15 * 60 * 1000; diff --git a/facerecognition_presensi/AndroidFolder/constants/theme.ts b/facerecognition_presensi/AndroidFolder/constants/theme.ts new file mode 100644 index 0000000..f06facd --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/constants/theme.ts @@ -0,0 +1,53 @@ +/** + * Below are the colors that are used in the app. The colors are defined in the light and dark mode. + * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. + */ + +import { Platform } from 'react-native'; + +const tintColorLight = '#0a7ea4'; +const tintColorDark = '#fff'; + +export const Colors = { + light: { + text: '#11181C', + background: '#fff', + tint: tintColorLight, + icon: '#687076', + tabIconDefault: '#687076', + tabIconSelected: tintColorLight, + }, + dark: { + text: '#ECEDEE', + background: '#151718', + tint: tintColorDark, + icon: '#9BA1A6', + tabIconDefault: '#9BA1A6', + tabIconSelected: tintColorDark, + }, +}; + +export const Fonts = Platform.select({ + ios: { + /** iOS `UIFontDescriptorSystemDesignDefault` */ + sans: 'system-ui', + /** iOS `UIFontDescriptorSystemDesignSerif` */ + serif: 'ui-serif', + /** iOS `UIFontDescriptorSystemDesignRounded` */ + rounded: 'ui-rounded', + /** iOS `UIFontDescriptorSystemDesignMonospaced` */ + mono: 'ui-monospace', + }, + default: { + sans: 'normal', + serif: 'serif', + rounded: 'normal', + mono: 'monospace', + }, + web: { + sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", + serif: "Georgia, 'Times New Roman', serif", + rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif", + mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", + }, +}); diff --git a/facerecognition_presensi/AndroidFolder/contexts/AuthContext.tsx b/facerecognition_presensi/AndroidFolder/contexts/AuthContext.tsx new file mode 100644 index 0000000..95a2830 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/contexts/AuthContext.tsx @@ -0,0 +1,260 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { AppState } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as SecureStore from "expo-secure-store"; +import { API_BASE, IDLE_TIMEOUT_MS } from "@/constants/config"; + +const STORAGE_KEYS = ["auth_token", "auth_user", "last_activity_at"] as const; +const SECURE_KEYS = { + EMAIL: "remembered_email", + PASSWORD: "remembered_password", + REMEMBER: "remember_remember", +} as const; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export interface AuthUser { + id: number; + name: string; + email: string; + role: string; + // Mahasiswa profile (populated after login via /users/{id}) + id_mahasiswa?: number; + nim?: string; + nama_mahasiswa?: string; + nama_kelas?: string; + id_prodi?: number; + id_angkatan?: number; + id_golongan?: number; + id_kelas?: number | null; +} + +interface AuthContextType { + token: string | null; + user: AuthUser | null; + isLoading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + /** Panggil setiap ada interaksi pengguna untuk mereset timer idle */ + updateActivity: () => void; +} + +// ─── Context ──────────────────────────────────────────────────────────────── + +const AuthContext = createContext(undefined); + +// ─── Provider ─────────────────────────────────────────────────────────────── + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [token, setToken] = useState(null); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Ref untuk waktu aktivitas terakhir — pakai ref agar update cepat tanpa re-render + const lastActivityRef = useRef(null); + + // ── Helpers ────────────────────────────────────────────────────────────── + + const clearSession = async () => { + await AsyncStorage.multiRemove([...STORAGE_KEYS]); + // Clear remembered credentials from secure storage + await Promise.all([ + SecureStore.deleteItemAsync(SECURE_KEYS.EMAIL), + SecureStore.deleteItemAsync(SECURE_KEYS.PASSWORD), + SecureStore.deleteItemAsync(SECURE_KEYS.REMEMBER), + ]); + lastActivityRef.current = null; + setToken(null); + setUser(null); + }; + + const isExpired = (lastActivity: number | null): boolean => { + if (!lastActivity) return false; // belum ada data aktivitas, jangan expire + return Date.now() - lastActivity > IDLE_TIMEOUT_MS; + }; + + // ── updateActivity ──────────────────────────────────────────────────────── + + const updateActivity = () => { + const now = Date.now(); + lastActivityRef.current = now; + // Fire-and-forget — tidak perlu await agar tidak memblokir UI + AsyncStorage.setItem("last_activity_at", String(now)).catch(() => {}); + }; + + // ── Hydrate dari AsyncStorage saat boot ────────────────────────────────── + + useEffect(() => { + const hydrate = async () => { + try { + const [storedToken, storedUser, storedActivity] = await Promise.all([ + AsyncStorage.getItem("auth_token"), + AsyncStorage.getItem("auth_user"), + AsyncStorage.getItem("last_activity_at"), + ]); + + if (!storedToken || !storedUser) return; + + // Cek apakah session sudah idle timeout + const lastActivity = storedActivity + ? parseInt(storedActivity, 10) + : null; + if (isExpired(lastActivity)) { + // Session kadaluarsa — bersihkan tanpa re-login + await AsyncStorage.multiRemove([...STORAGE_KEYS]); + return; + } + + lastActivityRef.current = lastActivity ?? Date.now(); + setToken(storedToken); + setUser(JSON.parse(storedUser) as AuthUser); + } catch { + // Storage gagal — mulai sebagai guest + } finally { + setIsLoading(false); + } + }; + hydrate(); + }, []); + + // ── Idle timeout checker ───────────────────────────────────────────────── + + useEffect(() => { + if (!token) return; + + const checkIdle = async () => { + if (isExpired(lastActivityRef.current)) { + await clearSession(); + } + }; + + // Cek setiap menit + const interval = setInterval(checkIdle, 60_000); + + // Cek juga saat app kembali ke foreground (user balik setelah lama di background) + const appStateSub = AppState.addEventListener("change", (state) => { + if (state === "active") checkIdle(); + }); + + return () => { + clearInterval(interval); + appStateSub.remove(); + }; + }, [token]); + + // ── login ───────────────────────────────────────────────────────────────── + + const login = async (email: string, password: string): Promise => { + // Backend pakai OAuth2PasswordRequestForm — form-urlencoded, field 'username' + const body = `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`; + const res = await fetch(`${API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + if (res.status === 401 || res.status === 400) { + throw new Error("Email atau password salah"); + } + if (!res.ok) { + throw new Error(`Server error: ${res.status}`); + } + + const data = (await res.json()) as { + access_token: string; + token_type: string; + user?: AuthUser; + role?: string; + name?: string; + email?: string; + id?: number; + }; + + // Normalise — backend bisa kembalikan user nested atau flat + let rawUser: AuthUser = data.user ?? { + id: data.id ?? 0, + name: data.name ?? "", + email: data.email ?? email, + role: data.role ?? "", + }; + + // Hanya mahasiswa yang boleh login di app ini + if (rawUser.role !== "mahasiswa") { + throw new Error("Akses ditolak: Login hanya untuk mahasiswa"); + } + + const jwt = data.access_token; + + // Enrich dengan data mahasiswa (NIM, kelas, prodi, dll) + try { + const profileRes = await fetch(`${API_BASE}/users/${rawUser.id}`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + if (profileRes.ok) { + const profile = (await profileRes.json()) as { + id_mahasiswa?: number; + nim?: string; + nama_mahasiswa?: string; + nama_kelas?: string; + id_prodi?: number; + id_angkatan?: number; + id_golongan?: number; + }; + rawUser = { + ...rawUser, + id_mahasiswa: profile.id_mahasiswa, + nim: profile.nim, + nama_mahasiswa: profile.nama_mahasiswa, + nama_kelas: profile.nama_kelas, + id_prodi: profile.id_prodi, + id_angkatan: profile.id_angkatan, + id_golongan: profile.id_golongan, + }; + } + } catch { + /* profil gagal diambil — lanjut dengan data minimal */ + } + + // Simpan token, user, dan waktu aktivitas awal + const now = Date.now(); + await Promise.all([ + AsyncStorage.setItem("auth_token", jwt), + AsyncStorage.setItem("auth_user", JSON.stringify(rawUser)), + AsyncStorage.setItem("last_activity_at", String(now)), + ]); + + lastActivityRef.current = now; + setToken(jwt); + setUser(rawUser); + }; + + // ── logout ──────────────────────────────────────────────────────────────── + + const logout = async (): Promise => { + await clearSession(); + }; + + // ───────────────────────────────────────────────────────────────────────── + + return ( + + {children} + + ); +} + +// ─── Hook ──────────────────────────────────────────────────────────────────── + +export function useAuth(): AuthContextType { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used inside "); + return ctx; +} diff --git a/facerecognition_presensi/AndroidFolder/contexts/ThemeContext.tsx b/facerecognition_presensi/AndroidFolder/contexts/ThemeContext.tsx new file mode 100644 index 0000000..9cd00ad --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/contexts/ThemeContext.tsx @@ -0,0 +1,195 @@ +import React, { createContext, useContext, useState, useEffect } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +export type ThemeMode = "light" | "dark"; + +export interface ThemeColors { + bg: string; + cardBg: string; + textPrimary: string; + textSecondary: string; + textMuted: string; + border: string; + divider: string; + accent: string; + accentLight: string; + accentDark: string; + headerBg: string; + shadowColor: string; + statusPillBg: string; + statusPillText: string; + circle1: string; + circle2: string; + subtitleText: string; + outerRing: string; + innerRing: string; + sectionTitle: string; + settingsCardBg: string; + settingsIconBg: string; + settingsIconColor: string; + chevronColor: string; + logoutBg: string; + logoutBorder: string; + logoutIconBg: string; + switchBorder: string; + dayCardBg: string; + dayCardBorder: string; + dayCardActiveBg: string; + dayCardActiveText: string; + timeText: string; + timeTextMuted: string; + badgeBg: string; + badgeText: string; +} + +export const lightTheme: ThemeColors = { + bg: "#F8FAFC", + cardBg: "#FFFFFF", + textPrimary: "#0F172A", + textSecondary: "#64748B", + textMuted: "#94A3B8", + border: "#E2E8F0", + divider: "#F1F5F9", + accent: "#2563EB", + accentLight: "#EFF6FF", + accentDark: "#1D4ED8", + headerBg: "#2563EB", + shadowColor: "#1E3A8A", + statusPillBg: "#DCFCE7", + statusPillText: "#15803D", + circle1: "#3B82F6", + circle2: "#60A5FA", + subtitleText: "#93C5FD", + outerRing: "#DBEAFE", + innerRing: "#FFFFFF", + sectionTitle: "#0F172A", + settingsCardBg: "#FFFFFF", + settingsIconBg: "#EFF6FF", + settingsIconColor: "#2563EB", + chevronColor: "#94A3B8", + logoutBg: "#FEF2F2", + logoutBorder: "#FEE2E2", + logoutIconBg: "#FEE2E2", + switchBorder: "#E2E8F0", + dayCardBg: "#FFFFFF", + dayCardBorder: "#E2E8F0", + dayCardActiveBg: "#2563EB", + dayCardActiveText: "#FFFFFF", + timeText: "#1E293B", + timeTextMuted: "#64748B", + badgeBg: "#EFF6FF", + badgeText: "#2563EB", +}; + +export const darkTheme: ThemeColors = { + bg: "#0F172A", + cardBg: "#1E293B", + textPrimary: "#F1F5F9", + textSecondary: "#94A3B8", + textMuted: "#64748B", + border: "#334155", + divider: "#334155", + accent: "#3B82F6", + accentLight: "rgba(59, 130, 246, 0.15)", + accentDark: "#1E3A8A", + headerBg: "#1E3A5F", + shadowColor: "#000000", + statusPillBg: "#14532D", + statusPillText: "#86EFAC", + circle1: "#2D4A6F", + circle2: "#3D5A7F", + subtitleText: "#93C5FD", + outerRing: "#334155", + innerRing: "#1E293B", + sectionTitle: "#F1F5F9", + settingsCardBg: "#1E293B", + settingsIconBg: "#1E3A5F", + settingsIconColor: "#60A5FA", + chevronColor: "#475569", + logoutBg: "#371B1B", + logoutBorder: "#5C2A2A", + logoutIconBg: "#5C2A2A", + switchBorder: "#334155", + dayCardBg: "#1E293B", + dayCardBorder: "#334155", + dayCardActiveBg: "#3B82F6", + dayCardActiveText: "#FFFFFF", + timeText: "#F1F5F9", + timeTextMuted: "#94A3B8", + badgeBg: "rgba(59, 130, 246, 0.2)", + badgeText: "#60A5FA", +}; + +const THEME_STORAGE_KEY = "@app_theme_mode"; + +interface ThemeContextType { + themeMode: ThemeMode; + isDarkMode: boolean; + theme: ThemeColors; + toggleDarkMode: () => void; + setThemeMode: (mode: ThemeMode) => void; +} + +const ThemeContext = createContext(undefined); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [themeMode, setThemeModeState] = useState("light"); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + const loadSavedTheme = async () => { + try { + const savedMode = await AsyncStorage.getItem(THEME_STORAGE_KEY); + if (savedMode === "dark" || savedMode === "light") { + setThemeModeState(savedMode); + } + } catch (error) { + console.error("Failed to load theme preference from storage:", error); + } finally { + setIsLoaded(true); + } + }; + loadSavedTheme(); + }, []); + + const setThemeMode = async (mode: ThemeMode) => { + setThemeModeState(mode); + try { + await AsyncStorage.setItem(THEME_STORAGE_KEY, mode); + } catch (error) { + console.error("Failed to save theme preference to storage:", error); + } + }; + + const toggleDarkMode = () => { + const nextMode = themeMode === "dark" ? "light" : "dark"; + setThemeMode(nextMode); + }; + + const isDarkMode = themeMode === "dark"; + const theme = isDarkMode ? darkTheme : lightTheme; + + return ( + + {children} + + ); +}; + +export const useTheme = (): ThemeContextType => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +}; diff --git a/facerecognition_presensi/AndroidFolder/eslint.config.js b/facerecognition_presensi/AndroidFolder/eslint.config.js new file mode 100644 index 0000000..5025da6 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/eslint.config.js @@ -0,0 +1,10 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config'); +const expoConfig = require('eslint-config-expo/flat'); + +module.exports = defineConfig([ + expoConfig, + { + ignores: ['dist/*'], + }, +]); diff --git a/facerecognition_presensi/AndroidFolder/hooks/use-color-scheme.ts b/facerecognition_presensi/AndroidFolder/hooks/use-color-scheme.ts new file mode 100644 index 0000000..17e3c63 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/hooks/use-color-scheme.ts @@ -0,0 +1 @@ +export { useColorScheme } from 'react-native'; diff --git a/facerecognition_presensi/AndroidFolder/hooks/use-color-scheme.web.ts b/facerecognition_presensi/AndroidFolder/hooks/use-color-scheme.web.ts new file mode 100644 index 0000000..7eb1c1b --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/hooks/use-color-scheme.web.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; +import { useColorScheme as useRNColorScheme } from 'react-native'; + +/** + * To support static rendering, this value needs to be re-calculated on the client side for web + */ +export function useColorScheme() { + const [hasHydrated, setHasHydrated] = useState(false); + + useEffect(() => { + setHasHydrated(true); + }, []); + + const colorScheme = useRNColorScheme(); + + if (hasHydrated) { + return colorScheme; + } + + return 'light'; +} diff --git a/facerecognition_presensi/AndroidFolder/hooks/use-theme-color.ts b/facerecognition_presensi/AndroidFolder/hooks/use-theme-color.ts new file mode 100644 index 0000000..0cbc3a6 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/hooks/use-theme-color.ts @@ -0,0 +1,21 @@ +/** + * Learn more about light and dark modes: + * https://docs.expo.dev/guides/color-schemes/ + */ + +import { Colors } from '@/constants/theme'; +import { useColorScheme } from '@/hooks/use-color-scheme'; + +export function useThemeColor( + props: { light?: string; dark?: string }, + colorName: keyof typeof Colors.light & keyof typeof Colors.dark +) { + const theme = useColorScheme() ?? 'light'; + const colorFromProps = props[theme]; + + if (colorFromProps) { + return colorFromProps; + } else { + return Colors[theme][colorName]; + } +} diff --git a/facerecognition_presensi/AndroidFolder/package-lock.json b/facerecognition_presensi/AndroidFolder/package-lock.json new file mode 100644 index 0000000..cd4e921 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/package-lock.json @@ -0,0 +1,12994 @@ +{ + "name": "androidfolder", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "androidfolder", + "version": "1.0.0", + "dependencies": { + "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-navigation/bottom-tabs": "^7.4.0", + "@react-navigation/elements": "^2.6.3", + "@react-navigation/native": "^7.1.8", + "expo": "~54.0.33", + "expo-camera": "~17.0.10", + "expo-constants": "~18.0.13", + "expo-font": "~14.0.11", + "expo-haptics": "~15.0.8", + "expo-image": "~3.0.11", + "expo-image-manipulator": "~14.0.8", + "expo-image-picker": "~17.0.11", + "expo-linear-gradient": "~15.0.8", + "expo-linking": "~8.0.11", + "expo-location": "~19.0.8", + "expo-router": "~6.0.23", + "expo-secure-store": "~15.0.8", + "expo-splash-screen": "~31.0.13", + "expo-status-bar": "~3.0.9", + "expo-symbols": "~1.0.8", + "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.10", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-maps": "^1.20.1", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-web": "~0.21.0", + "react-native-webview": "13.15.0", + "react-native-worklets": "0.5.1" + }, + "devDependencies": { + "@types/react": "~19.1.0", + "eslint": "^9.25.0", + "eslint-config-expo": "~10.0.0", + "typescript": "~5.9.2" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", + "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", + "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", + "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", + "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-decorators": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", + "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz", + "integrity": "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", + "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse--for-generate-function-map": { + "name": "@babel/traverse", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/config": { + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", + "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "^10.0.8", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4", + "sucrase": "~3.35.1" + } + }, + "node_modules/@expo/config-plugins": { + "version": "54.0.4", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", + "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^54.0.10", + "@expo/json-file": "~10.0.8", + "@expo/plist": "^0.4.8", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/config-types": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", + "license": "MIT" + }, + "node_modules/@expo/config/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/config/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "license": "MIT", + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", + "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/env": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.8.tgz", + "integrity": "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0" + } + }, + "node_modules/@expo/fingerprint": { + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz", + "integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^9.0.0", + "p-limit": "^3.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/fingerprint/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@expo/fingerprint/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.8.tgz", + "integrity": "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "resolve-from": "^5.0.0", + "resolve-global": "^1.0.0", + "semver": "^7.6.0", + "temp-dir": "~2.0.0", + "unique-string": "~2.0.0" + } + }, + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/json-file": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", + "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/json-file/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/metro": { + "version": "54.2.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", + "integrity": "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==", + "license": "MIT", + "dependencies": { + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3" + } + }, + "node_modules/@expo/metro-config": { + "version": "54.0.14", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz", + "integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8", + "@expo/json-file": "~10.0.8", + "@expo/metro": "~54.2.0", + "@expo/spawn-async": "^1.7.2", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.29.1", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "minimatch": "^9.0.0", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@expo/metro-config/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@expo/metro-config/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/metro-runtime": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", + "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", + "license": "MIT", + "dependencies": { + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@expo/osascript": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz", + "integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "exec-async": "^2.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.10.tgz", + "integrity": "sha512-axJm+NOj3jVxep49va/+L3KkF3YW/dkV+RwzqUJedZrv4LeTqOG4rhrCaCPXHTvLqCTDKu6j0Xyd28N7mnxsGA==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^10.0.8", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/plist": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", + "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/prebuild-config": { + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz", + "integrity": "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", + "@react-native/normalize-colors": "0.81.5", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/schema-utils": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", + "integrity": "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==", + "license": "MIT" + }, + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "license": "MIT" + }, + "node_modules/@expo/spawn-async": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", + "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT" + }, + "node_modules/@expo/vector-icons": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.0.3.tgz", + "integrity": "sha512-SBUyYKphmlfUBqxSfDdJ3jAdEVSALS2VUPOUyqn48oZmb2TL/O7t7/PQm5v4NQujYEPLPMTLn9KVw6H7twwbTA==", + "license": "MIT", + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/ws-tunnel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", + "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", + "license": "MIT" + }, + "node_modules/@expo/xcpretty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.0.tgz", + "integrity": "sha512-o2qDlTqJ606h4xR36H2zWTywmZ3v3842K6TU8Ik2n1mfW0S580VHlt3eItVYdLYz+klaPp7CXqanja8eASZjRw==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", + "integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz", + "integrity": "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.81.5" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", + "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/template": "^7.25.0", + "@react-native/babel-plugin-codegen": "0.81.5", + "babel-plugin-syntax-hermes-parser": "0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", + "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "glob": "^7.1.1", + "hermes-parser": "0.29.1", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz", + "integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.81.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.83.1", + "metro-config": "^0.83.1", + "metro-core": "^0.83.1", + "semver": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "*" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz", + "integrity": "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz", + "integrity": "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.81.5", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.2.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^6.2.3" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz", + "integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz", + "integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", + "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", + "license": "MIT" + }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.12.0.tgz", + "integrity": "sha512-/GtOfVWRligHG0mvX39I1FGdUWeWl0GVF2okEziQSQj0bOTrLIt7y44C3r/aCLkEpTVltCPGM3swqGTH3UfRCw==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.5", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.28", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.14.0.tgz", + "integrity": "sha512-tMpzskBzVp0E7CRNdNtJIdXjk54Kwe/TF9ViXAef+YFM1kSfGv4e/B2ozfXE+YyYgmh4WavTv8fkdJz1CNyu+g==", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^7.5.3", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.5.tgz", + "integrity": "sha512-iHZU8rRN1014Upz73AqNVXDvSMZDh5/ktQ1CMe21rdgnOY79RWtHHBp9qOS3VtqlUVYGkuX5GEw5mDt4tKdl0g==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.1.28", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.1.28", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz", + "integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.14.0", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/native-stack": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.12.0.tgz", + "integrity": "sha512-XmNJsPshjkNsahgbxNgGWQUq4s1l6HqH/Fei4QsjBNn/0mTvVrRVZwJ1XrY9YhWYvyiYkAN6/OmarWQaQJ0otQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.5", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.28", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", + "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", + "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@urql/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", + "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.13", + "wonka": "^6.3.2" + } + }, + "node_modules/@urql/exchange-retry": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", + "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", + "license": "MIT", + "dependencies": { + "@urql/core": "^5.1.2", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^5.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", + "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.6", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", + "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.6" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz", + "integrity": "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.29.1" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-expo": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz", + "integrity": "sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.81.5", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + } + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "license": "MIT", + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/better-opn/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001767", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", + "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", + "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", + "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/env-editor": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", + "integrity": "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-expo": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-expo/-/eslint-config-expo-10.0.0.tgz", + "integrity": "sha512-/XC/DvniUWTzU7Ypb/cLDhDD4DXqEio4lug1ObD/oQ9Hcx3OVOR8Mkp4u6U4iGoZSJyIQmIk3WVHe/P1NYUXKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "^8.18.2", + "@typescript-eslint/parser": "^8.18.2", + "eslint-import-resolver-typescript": "^3.6.3", + "eslint-plugin-expo": "^1.0.0", + "eslint-plugin-import": "^2.30.0", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^5.1.0", + "globals": "^16.0.0" + }, + "peerDependencies": { + "eslint": ">=8.10" + } + }, + "node_modules/eslint-config-expo/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-expo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-expo/-/eslint-plugin-expo-1.0.0.tgz", + "integrity": "sha512-qLtunR+cNFtC+jwYCBia5c/PJurMjSLMOV78KrEOyQK02ohZapU4dCFFnS2hfrJuw0zxfsjVkjqg3QBqi933QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.29.1", + "@typescript-eslint/utils": "^8.29.1", + "eslint": "^9.24.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "eslint": ">=8.10" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/exec-async": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", + "integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==", + "license": "MIT" + }, + "node_modules/expo": { + "version": "54.0.33", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", + "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "54.0.23", + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/devtools": "0.1.8", + "@expo/fingerprint": "0.15.4", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "54.0.14", + "@expo/vector-icons": "^15.0.3", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~54.0.10", + "expo-asset": "~12.0.12", + "expo-constants": "~18.0.13", + "expo-file-system": "~19.0.21", + "expo-font": "~14.0.11", + "expo-keep-awake": "~15.0.8", + "expo-modules-autolinking": "3.0.24", + "expo-modules-core": "3.0.29", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-without-unicode": "8.0.0-3" + }, + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-native": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-asset": { + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", + "integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.8", + "expo-constants": "~18.0.12" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-camera": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", + "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-constants": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-file-system": { + "version": "19.0.21", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", + "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "14.0.11", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", + "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-haptics": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz", + "integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.11.tgz", + "integrity": "sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-image-loader": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", + "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-manipulator": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz", + "integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz", + "integrity": "sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-keep-awake": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", + "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-linear-gradient": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", + "integrity": "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-linking": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", + "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", + "license": "MIT", + "dependencies": { + "expo-constants": "~18.0.12", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-location": { + "version": "19.0.8", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz", + "integrity": "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", + "integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.1.0", + "commander": "^7.2.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-core": { + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz", + "integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-router": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", + "integrity": "sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==", + "license": "MIT", + "dependencies": { + "@expo/metro-runtime": "^6.1.2", + "@expo/schema-utils": "^0.1.8", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-tabs": "^1.1.12", + "@react-navigation/bottom-tabs": "^7.4.0", + "@react-navigation/native": "^7.1.8", + "@react-navigation/native-stack": "^7.3.16", + "client-only": "^0.0.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "expo-server": "^1.0.5", + "fast-deep-equal": "^3.1.3", + "invariant": "^2.2.4", + "nanoid": "^3.3.8", + "query-string": "^7.1.3", + "react-fast-compare": "^3.2.2", + "react-native-is-edge-to-edge": "^1.1.6", + "semver": "~7.6.3", + "server-only": "^0.0.1", + "sf-symbols-typescript": "^2.1.0", + "shallowequal": "^1.1.0", + "use-latest-callback": "^0.2.1", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@expo/metro-runtime": "^6.1.2", + "@react-navigation/drawer": "^7.5.0", + "@testing-library/react-native": ">= 12.0.0", + "expo": "*", + "expo-constants": "^18.0.13", + "expo-linking": "^8.0.11", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-gesture-handler": "*", + "react-native-reanimated": "*", + "react-native-safe-area-context": ">= 5.4.0", + "react-native-screens": "*", + "react-native-web": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "@react-navigation/drawer": { + "optional": true + }, + "@testing-library/react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-gesture-handler": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-server": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", + "integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo-splash-screen": { + "version": "31.0.13", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", + "integrity": "sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==", + "license": "MIT", + "dependencies": { + "@expo/prebuild-config": "^54.0.8" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-status-bar": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", + "integrity": "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.2.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-symbols": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-1.0.8.tgz", + "integrity": "sha512-7bNjK350PaQgxBf0owpmSYkdZIpdYYmaPttDBb2WIp6rIKtcEtdzdfmhsc2fTmjBURHYkg36+eCxBFXO25/1hw==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.0.0" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-system-ui": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz", + "integrity": "sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg==", + "license": "MIT", + "dependencies": { + "@react-native/normalize-colors": "0.81.5", + "debug": "^4.3.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-web-browser": { + "version": "15.0.10", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", + "integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo/node_modules/@expo/cli": { + "version": "54.0.23", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", + "integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.8", + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.0.8", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "~54.0.14", + "@expo/osascript": "^2.3.8", + "@expo/package-manager": "^1.9.10", + "@expo/plist": "^0.4.8", + "@expo/prebuild-config": "^54.0.8", + "@expo/schema-utils": "^0.1.8", + "@expo/spawn-async": "^1.7.2", + "@expo/ws-tunnel": "^1.0.1", + "@expo/xcpretty": "^4.3.0", + "@react-native/dev-middleware": "0.81.5", + "@urql/core": "^5.0.6", + "@urql/exchange-retry": "^1.3.0", + "accepts": "^1.3.8", + "arg": "^5.0.2", + "better-opn": "~3.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "env-editor": "^0.4.1", + "expo-server": "^1.0.5", + "freeport-async": "^2.0.0", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.1.6", + "minimatch": "^9.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^3.0.1", + "pretty-bytes": "^5.6.0", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "qrcode-terminal": "0.11.0", + "require-from-string": "^2.0.2", + "requireg": "^0.2.2", + "resolve": "^1.22.2", + "resolve-from": "^5.0.0", + "resolve.exports": "^2.0.3", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "source-map-support": "~0.5.21", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "tar": "^7.5.2", + "terminal-link": "^2.1.1", + "undici": "^6.18.2", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1" + }, + "bin": { + "expo-internal": "build/bin/cli" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/expo/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/expo/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo/node_modules/picomatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/expo/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo/node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/freeport-async": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", + "integrity": "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", + "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", + "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.2", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-dirs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", + "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", + "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", + "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.29.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lan-network": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz", + "integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==", + "license": "MIT", + "bin": { + "lan-network": "dist/lan-network-cli.js" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/metro": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz", + "integrity": "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "@babel/types": "^7.25.2", + "accepts": "^1.3.7", + "chalk": "^4.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.32.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3", + "mime-types": "^2.1.27", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz", + "integrity": "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.32.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/metro-cache": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz", + "integrity": "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-cache-key": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.3.tgz", + "integrity": "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-config": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz", + "integrity": "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.83.3", + "metro-cache": "0.83.3", + "metro-core": "0.83.3", + "metro-runtime": "0.83.3", + "yaml": "^2.6.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-core": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz", + "integrity": "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-file-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.3.tgz", + "integrity": "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz", + "integrity": "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-resolver": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.3.tgz", + "integrity": "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-runtime": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz", + "integrity": "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-source-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz", + "integrity": "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.83.3", + "nullthrows": "^1.1.1", + "ob1": "0.83.3", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz", + "integrity": "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.83.3", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz", + "integrity": "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz", + "integrity": "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-source-map": "0.83.3", + "metro-transform-plugins": "0.83.3", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nested-error-stacks": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz", + "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/ob1": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.3.tgz", + "integrity": "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "license": "MIT", + "dependencies": { + "pngjs": "^3.3.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode-terminal": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", + "integrity": "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", + "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", + "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/assets-registry": "0.81.5", + "@react-native/codegen": "0.81.5", + "@react-native/community-cli-plugin": "0.81.5", + "@react-native/gradle-plugin": "0.81.5", + "@react-native/js-polyfills": "0.81.5", + "@react-native/normalize-colors": "0.81.5", + "@react-native/virtualized-lists": "0.81.5", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-jest": "^29.7.0", + "babel-plugin-syntax-hermes-parser": "0.29.1", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "glob": "^7.1.1", + "invariant": "^2.2.4", + "jest-environment-node": "^29.7.0", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.83.1", + "metro-source-map": "^0.83.1", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.26.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0", + "ws": "^6.2.3", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.0", + "react": "^19.1.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-gesture-handler": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", + "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", + "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-maps": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.20.1.tgz", + "integrity": "sha512-NZI3B5Z6kxAb8gzb2Wxzu/+P2SlFIg1waHGIpQmazDSCRkNoHNY4g96g+xS0QPSaG/9xRBbDNnd2f2/OW6t6LQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.13" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">= 17.0.1", + "react-native": ">= 0.64.3", + "react-native-web": ">= 0.11" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/react-native-reanimated": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", + "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.2.1", + "semver": "7.7.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0", + "react": "*", + "react-native": "*", + "react-native-worklets": ">=0.5.0" + } + }, + "node_modules/react-native-reanimated/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", + "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", + "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "react-native-is-edge-to-edge": "^1.2.1", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", + "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/react-native-webview": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz", + "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", + "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.0.0-0", + "@babel/plugin-transform-class-properties": "^7.0.0-0", + "@babel/plugin-transform-classes": "^7.0.0-0", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", + "@babel/plugin-transform-optional-chaining": "^7.0.0-0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", + "@babel/plugin-transform-template-literals": "^7.0.0-0", + "@babel/plugin-transform-unicode-regex": "^7.0.0-0", + "@babel/preset-typescript": "^7.16.7", + "convert-source-map": "^2.0.0", + "semver": "7.7.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0", + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/@react-native/virtualized-lists": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", + "integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/react-native/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requireg": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz", + "integrity": "sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==", + "dependencies": { + "nested-error-stacks": "~2.0.1", + "rc": "~1.2.7", + "resolve": "~1.7.1" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/requireg/node_modules/resolve": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", + "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", + "license": "MIT", + "dependencies": { + "path-parse": "^1.0.5" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-global": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", + "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", + "license": "MIT", + "dependencies": { + "global-dirs": "^0.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", + "license": "MIT" + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sf-symbols-typescript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slugify": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", + "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT" + }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/whatwg-url-without-unicode": { + "version": "8.0.0-3", + "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", + "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==", + "license": "MIT", + "dependencies": { + "buffer": "^5.4.3", + "punycode": "^2.1.1", + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wonka": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", + "integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==", + "license": "MIT" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/facerecognition_presensi/AndroidFolder/package.json b/facerecognition_presensi/AndroidFolder/package.json new file mode 100644 index 0000000..26fef2d --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/package.json @@ -0,0 +1,56 @@ +{ + "name": "androidfolder", + "main": "expo-router/entry", + "version": "1.0.0", + "scripts": { + "start": "expo start", + "reset-project": "node ./scripts/reset-project.js", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web", + "lint": "expo lint" + }, + "dependencies": { + "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-navigation/bottom-tabs": "^7.4.0", + "@react-navigation/elements": "^2.6.3", + "@react-navigation/native": "^7.1.8", + "expo": "~54.0.33", + "expo-camera": "~17.0.10", + "expo-constants": "~18.0.13", + "expo-font": "~14.0.11", + "expo-haptics": "~15.0.8", + "expo-image": "~3.0.11", + "expo-image-manipulator": "~14.0.8", + "expo-image-picker": "~17.0.11", + "expo-linear-gradient": "~15.0.8", + "expo-linking": "~8.0.11", + "expo-location": "~19.0.8", + "expo-router": "~6.0.23", + "expo-secure-store": "~15.0.8", + "expo-splash-screen": "~31.0.13", + "expo-status-bar": "~3.0.9", + "expo-symbols": "~1.0.8", + "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.10", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-maps": "^1.20.1", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-web": "~0.21.0", + "react-native-webview": "13.15.0", + "react-native-worklets": "0.5.1" + }, + "devDependencies": { + "@types/react": "~19.1.0", + "eslint": "^9.25.0", + "eslint-config-expo": "~10.0.0", + "typescript": "~5.9.2" + }, + "private": true +} diff --git a/facerecognition_presensi/AndroidFolder/scripts/reset-project.js b/facerecognition_presensi/AndroidFolder/scripts/reset-project.js new file mode 100644 index 0000000..51dff15 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/scripts/reset-project.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +/** + * This script is used to reset the project to a blank state. + * It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file. + * You can remove the `reset-project` script from package.json and safely delete this file after running it. + */ + +const fs = require("fs"); +const path = require("path"); +const readline = require("readline"); + +const root = process.cwd(); +const oldDirs = ["app", "components", "hooks", "constants", "scripts"]; +const exampleDir = "app-example"; +const newAppDir = "app"; +const exampleDirPath = path.join(root, exampleDir); + +const indexContent = `import { Text, View } from "react-native"; + +export default function Index() { + return ( + + Edit app/index.tsx to edit this screen. + + ); +} +`; + +const layoutContent = `import { Stack } from "expo-router"; + +export default function RootLayout() { + return ; +} +`; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +const moveDirectories = async (userInput) => { + try { + if (userInput === "y") { + // Create the app-example directory + await fs.promises.mkdir(exampleDirPath, { recursive: true }); + console.log(`📁 /${exampleDir} directory created.`); + } + + // Move old directories to new app-example directory or delete them + for (const dir of oldDirs) { + const oldDirPath = path.join(root, dir); + if (fs.existsSync(oldDirPath)) { + if (userInput === "y") { + const newDirPath = path.join(root, exampleDir, dir); + await fs.promises.rename(oldDirPath, newDirPath); + console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`); + } else { + await fs.promises.rm(oldDirPath, { recursive: true, force: true }); + console.log(`❌ /${dir} deleted.`); + } + } else { + console.log(`➡️ /${dir} does not exist, skipping.`); + } + } + + // Create new /app directory + const newAppDirPath = path.join(root, newAppDir); + await fs.promises.mkdir(newAppDirPath, { recursive: true }); + console.log("\n📁 New /app directory created."); + + // Create index.tsx + const indexPath = path.join(newAppDirPath, "index.tsx"); + await fs.promises.writeFile(indexPath, indexContent); + console.log("📄 app/index.tsx created."); + + // Create _layout.tsx + const layoutPath = path.join(newAppDirPath, "_layout.tsx"); + await fs.promises.writeFile(layoutPath, layoutContent); + console.log("📄 app/_layout.tsx created."); + + console.log("\n✅ Project reset complete. Next steps:"); + console.log( + `1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${ + userInput === "y" + ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.` + : "" + }` + ); + } catch (error) { + console.error(`❌ Error during script execution: ${error.message}`); + } +}; + +rl.question( + "Do you want to move existing files to /app-example instead of deleting them? (Y/n): ", + (answer) => { + const userInput = answer.trim().toLowerCase() || "y"; + if (userInput === "y" || userInput === "n") { + moveDirectories(userInput).finally(() => rl.close()); + } else { + console.log("❌ Invalid input. Please enter 'Y' or 'N'."); + rl.close(); + } + } +); diff --git a/facerecognition_presensi/AndroidFolder/tsconfig.json b/facerecognition_presensi/AndroidFolder/tsconfig.json new file mode 100644 index 0000000..909e901 --- /dev/null +++ b/facerecognition_presensi/AndroidFolder/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ] +} diff --git a/facerecognition_presensi/CLEANUP_REPORT.md b/facerecognition_presensi/CLEANUP_REPORT.md new file mode 100644 index 0000000..5080b6f --- /dev/null +++ b/facerecognition_presensi/CLEANUP_REPORT.md @@ -0,0 +1,39 @@ +# Hasil pembersihan — 26 Oktober 2026 + +## Yang dikerjakan + +- 153 target terdaftar (10.546 file, sekitar 1.298 MiB) dipindahkan melalui cadangan sementara di luar proyek, lalu dihapus dari proyek. Termasuk dataset/foto, template identitas, upload, hasil pelatihan lama, arsip, laporan dan generator laporan, kode ML lama, konfigurasi lokal, serta hasil analisis graphify. +- Riwayat Git terpisah Android dicadangkan di luar proyek dan dihapus agar source Android dapat masuk repo root sebagai file biasa. Tidak ada commit/push yang dilakukan. +- Gambar demo SilentFace ikut dicadangkan dan dihapus; bobot umum, source, dan LICENSE tetap dipertahankan. Ikon/logo UI yang diperlukan aplikasi tetap ada. +- .env asli backend/website dihapus; contoh konfigurasi tiga aplikasi ditambahkan. SECRET_KEY wajib diisi sendiri dan placeholder contoh ditolak. Akun admin bawaan diubah menjadi pembuatan interaktif. +- Ditambahkan init_db.py yang membuat tabel tanpa drop; panduan membedakan instalasi baru dengan patch database historis dan skrip reset destruktif. +- Ditambahkan onnxruntime, pin bcrypt kompatibel Passlib, dan hanya opencv-contrib-python untuk menghindari dua paket OpenCV saling menimpa. +- URL database mendukung karakter khusus pada password. URL Android kini memakai EXPO_PUBLIC_API_URL. Kesalahan tipe handler tombol scan Android diperbaiki. +- Paket native Android dipertahankan dan aturan ignore yang sebelumnya membuang seluruh folder android dihapus. + +## Verifikasi aktual + +- Build produksi website berhasil (1.802 modul). Ada peringatan ukuran bundle dan data Browserslist lama. +- Pemeriksaan TypeScript Android (`tsc --noEmit`) berhasil setelah perbaikan handler. +- Backend berhasil diimpor. Handler health berhasil; skema OpenAPI dengan 102 path berhasil dibentuk. +- Definisi 20 tabel berhasil dikompilasi menjadi DDL MySQL tanpa menjalankan perubahan pada database pengguna. +- Password database dengan karakter khusus lolos pemeriksaan pembentukan URL. +- EnhancedFaceRecognitionSystem berhasil dimuat dengan gallery kosong setelah foto dan metadata identitas dihapus. +- Sintaks source Python lolos. Inventaris source/aset di luar dependensi dan cache tidak menemukan .env asli, foto JPEG, dump SQL, metadata pickle, arsip, atau dokumen laporan. Pemindaian pola token GitHub/AWS/JWT/private key tidak menemukan kecocokan. Pemindaian pola bukan jaminan mendeteksi segala bentuk rahasia. +- Audit Git sementara memeriksa 276 kandidat file; .env.example, model dasar dan source native Android tercakup, sedangkan data sensitif/dependensi dikecualikan. +- MySQL baru, login, enrollment, kamera, geolokasi, build APK dan presensi menyeluruh belum diuji. Tidak ada database pengguna yang dimigrasikan/dihapus. + +## Model umum yang dipertahankan + +SHA-256: + +- face_detection_yunet_2023mar.onnx: `8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4` +- mobilefacenet.onnx: `4c06341c33c2ca1f86781dab0e829f88ad5b64be9fba56e56bc9ebdefc619e43` + +Hash mencatat file lokal; bukan bukti asal atau lisensi. Asal/lisensi bobot MobileFaceNet perlu dipastikan sebelum redistribusi publik. + +## Catatan lokal + +Dependensi Website/Android dan virtual environment backend digunakan untuk verifikasi dan diabaikan Git; tidak perlu diunggah. Aturan agen lokal .agents juga diabaikan. Root .git awal kosong, bukan repositori dengan riwayat valid. + +Cadangan sementara berisi data sensitif berada di folder Temp Windows di luar proyek; jangan unggah cadangan tersebut. Lokasinya telah disampaikan dalam hasil proses pembersihan. Folder sementara dapat dibersihkan Windows dan bukan arsip permanen. Panduan instalasi ada di README.md. diff --git a/facerecognition_presensi/ModelBaru/adaptive_fusion.py b/facerecognition_presensi/ModelBaru/adaptive_fusion.py new file mode 100644 index 0000000..1382db7 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/adaptive_fusion.py @@ -0,0 +1,525 @@ +""" +Adaptive Feature Fusion Module +============================== +Enhanced fusion with: +1. Dense projection layers for each feature branch +2. L2 normalization per branch +3. Adaptive weight adjustment based on image quality +4. Weighted combination + +This replaces the simple concatenation with a learned projection approach. + +Author: Skripsi 2026 +""" + +import numpy as np +from typing import Dict, Tuple, Optional +import logging +import os + +from config import ( + LBPH_DIM, CNN_DIM, FUSION_DIM, + FUSION_HIDDEN_DIM, FINAL_EMBEDDING_DIM, + ADAPTIVE_FUSION_CONFIG, IMG_SIZE_LBPH, IMG_SIZE_CNN +) + +log = logging.getLogger(__name__) + + +class AdaptiveFusion: + """ + Adaptive Feature Fusion with learned projections. + + Architecture: + ┌─────────────────────────────────────────────────────────────┐ + │ │ + │ LBPH Features ──► Dense(256) ──► L2Norm ──┐ │ + │ (640-D) │ │ + │ │ │ + │ ▼ │ + │ MobileFaceNet ──► Dense(256) ──► L2Norm ──┼──► Fuse │ + │ (512-D) │ │ + │ │ │ + │ Quality Scores ──► Weight Calculator ──────┘ │ + │ │ + └─────────────────────────────────────────────────────────────┘ + + Instead of simple concatenation: + - Each branch is projected to a common space (256-D) + - L2 normalized + - Weighted combination based on quality + """ + + def __init__(self, use_learned_fusion: bool = True): + """ + Initialize Adaptive Fusion. + + Args: + use_learned_fusion: If True, use learned projections. + If False, use weighted simple fusion. + """ + self.use_learned_fusion = use_learned_fusion + self.config = ADAPTIVE_FUSION_CONFIG + + # Initialize projection matrices (simulated Dense layers) + # These can be learned from data or pre-set + self._init_projections() + + # Batch normalization stats (for stability) + self._bn_running_mean_lbph = np.zeros(LBPH_DIM) + self._bn_running_var_lbph = np.ones(LBPH_DIM) + self._bn_running_mean_cnn = np.zeros(CNN_DIM) + self._bn_running_var_cnn = np.ones(CNN_DIM) + + def _init_projections(self): + """Initialize projection matrices for Dense layers.""" + # Xavier/Glorot initialization + scale_lbph = np.sqrt(2.0 / (LBPH_DIM + FUSION_HIDDEN_DIM)) + scale_cnn = np.sqrt(2.0 / (CNN_DIM + FUSION_HIDDEN_DIM)) + + # Projection: 640 -> 256 for LBPH + self.W_lbph = np.random.randn(FUSION_HIDDEN_DIM, LBPH_DIM) * scale_lbph + self.b_lbph = np.zeros(FUSION_HIDDEN_DIM) + + # Projection: 512 -> 256 for MobileFaceNet + self.W_cnn = np.random.randn(FUSION_HIDDEN_DIM, CNN_DIM) * scale_cnn + self.b_cnn = np.zeros(FUSION_HIDDEN_DIM) + + def _l2_normalize(self, vector: np.ndarray, eps: float = 1e-7) -> np.ndarray: + """L2 normalize a vector.""" + norm = np.linalg.norm(vector) + if norm < eps: + return vector + return vector / norm + + def _batch_norm( + self, + x: np.ndarray, + running_mean: np.ndarray, + running_var: np.ndarray, + weight: np.ndarray = None, + bias: np.ndarray = None, + eps: float = 1e-5 + ) -> np.ndarray: + """Simulated batch normalization.""" + if len(x.shape) == 1: + x = x.reshape(1, -1) + + # Use running stats if available + mean = running_mean + var = running_var + + # Normalize + x_norm = (x - mean) / np.sqrt(var + eps) + + # Scale and shift + if weight is not None and bias is not None: + x_norm = x_norm * weight + bias + + return x_norm[0] if x_norm.shape[0] == 1 else x_norm + + def _dense_layer( + self, + x: np.ndarray, + W: np.ndarray, + b: np.ndarray, + activation: str = 'relu' + ) -> np.ndarray: + """ + Dense layer operation. + + Args: + x: Input vector + W: Weight matrix + b: Bias vector + activation: Activation function ('relu' or 'linear') + + Returns: + Output vector + """ + # Linear transformation + y = np.dot(W, x) + b + + # Activation + if activation == 'relu': + y = np.maximum(0, y) + + return y + + def project_lbph(self, lbph_features: np.ndarray) -> np.ndarray: + """ + Project LBPH features to common space. + + Args: + lbph_features: LBPH feature vector (640-D) + + Returns: + Projected vector (256-D), L2 normalized + """ + # Batch normalize + bn_weight = np.ones(LBPH_DIM) # Scale factor + bn_bias = np.zeros(LBPH_DIM) # Shift + + x = self._batch_norm( + lbph_features, + self._bn_running_mean_lbph, + self._bn_running_var_lbph, + bn_weight, bn_bias + ) + + # Dense layer + dense = self._dense_layer(x, self.W_lbph, self.b_lbph, activation='relu') + + # L2 normalize + return self._l2_normalize(dense) + + def project_mobilefacenet(self, cnn_features: np.ndarray) -> np.ndarray: + """ + Project MobileFaceNet features to common space. + + Args: + cnn_features: CNN feature vector (512-D) + + Returns: + Projected vector (256-D), L2 normalized + """ + # Batch normalize + bn_weight = np.ones(CNN_DIM) + bn_bias = np.zeros(CNN_DIM) + + x = self._batch_norm( + cnn_features, + self._bn_running_mean_cnn, + self._bn_running_var_cnn, + bn_weight, bn_bias + ) + + # Dense layer + dense = self._dense_layer(x, self.W_cnn, self.b_cnn, activation='relu') + + # L2 normalize + return self._l2_normalize(dense) + + def get_adaptive_weights( + self, + quality_scores: Optional[Dict] = None + ) -> Dict[str, float]: + """ + Calculate adaptive weights based on quality scores. + + Args: + quality_scores: Quality assessment dictionary (optional) + + Returns: + Dictionary with lbph and mobilefacenet weights + """ + if quality_scores is None or not self.config.get('enabled', True): + return self.config['default_weights'].copy() + + adj = self.config['quality_adjustment'] + + # Get individual scores + blur_score = quality_scores.get('blur', {}).get('score', 0.5) + brightness_score = quality_scores.get('brightness', {}).get('score', 0.5) + pose_score = quality_scores.get('pose', {}).get('score', 0.5) + total_score = quality_scores.get('total_score', 0.5) + + # Decision tree for weight selection + if blur_score < 0.5: + # High blur → rely more on MobileFaceNet (deeper features) + return adj['high_blur'].copy() + + elif brightness_score < 0.5: + # Bad lighting → rely more on LBPH (enhanced by CLAHE) + return adj['bad_lighting'].copy() + + elif pose_score < 0.6: + # Bad pose → rely more on MobileFaceNet + return adj['bad_pose'].copy() + + elif total_score >= 0.8: + # Excellent quality → balanced + return adj['excellent'].copy() + + else: + # Default balanced weights + return self.config['default_weights'].copy() + + def fuse( + self, + lbph_features: np.ndarray, + cnn_features: np.ndarray, + quality_scores: Optional[Dict] = None + ) -> np.ndarray: + """ + Fuse LBPH and MobileFaceNet features. + + Args: + lbph_features: LBPH feature vector (640-D) + cnn_features: MobileFaceNet feature vector (512-D) + quality_scores: Quality assessment (optional) + + Returns: + Final embedding vector (512-D), L2 normalized + """ + if self.use_learned_fusion: + # Method 1: Learned projection + weighted combination + # Project to common space + proj_lbph = self.project_lbph(lbph_features) + proj_cnn = self.project_mobilefacenet(cnn_features) + + # Get adaptive weights + weights = self.get_adaptive_weights(quality_scores) + + # Weighted combination + fused = ( + weights['lbph'] * proj_lbph + + weights['mobilefacenet'] * proj_cnn + ) + else: + # Method 2: Simple weighted concatenation (no learned projection) + # Get adaptive weights + weights = self.get_adaptive_weights(quality_scores) + + # Normalize each branch + norm_lbph = self._l2_normalize(lbph_features) + norm_cnn = self._l2_normalize(cnn_features) + + # Scale by weights and concatenate + fused = np.concatenate([ + weights['lbph'] * norm_lbph, + weights['mobilefacenet'] * norm_cnn + ]) + + # Final L2 normalization + return self._l2_normalize(fused) + + def extract_embedding( + self, + face: np.ndarray, + lbph_extractor, + cnn_extractor, + quality_scores: Optional[Dict] = None + ) -> np.ndarray: + """ + Full embedding extraction with adaptive fusion. + + Args: + face: Face crop (BGR) + lbph_extractor: LBPH extractor instance + cnn_extractor: MobileFaceNet extractor instance + quality_scores: Quality assessment (optional) + + Returns: + Final embedding (512-D) + """ + # Extract features + lbph_features = lbph_extractor.extract(face) + cnn_features = cnn_extractor.extract(face) + + # Fuse + return self.fuse(lbph_features, cnn_features, quality_scores) + + +class SimpleFusion: + """ + Simple concatenation fusion (for backward compatibility). + + This is the original simple fusion: + Concatenate → L2 Normalize + """ + + def __init__(self): + pass + + def fuse( + self, + lbph_features: np.ndarray, + cnn_features: np.ndarray, + quality_scores: Optional[Dict] = None + ) -> np.ndarray: + """ + Simple concatenation fusion. + + Args: + lbph_features: LBPH vector (640-D) + cnn_features: CNN vector (512-D) + quality_scores: Not used (for API compatibility) + + Returns: + Concatenated, L2 normalized vector (1152-D) + """ + # Concatenate + fused = np.concatenate([lbph_features, cnn_features]) + + # L2 normalize + norm = np.linalg.norm(fused) + if norm > 1e-7: + return fused / norm + return fused + + def get_adaptive_weights(self, quality_scores=None) -> Dict[str, float]: + """Return default weights (for API compatibility).""" + return {'lbph': 0.35, 'mobilefacenet': 0.65} + + +class WeightedSimpleFusion: + """ + Weighted simple fusion without projection layers. + + This is an intermediate approach: + 1. L2 normalize each branch + 2. Weight adjustment based on quality + 3. Weighted concatenation + """ + + def __init__(self): + self.config = ADAPTIVE_FUSION_CONFIG + + def _l2_normalize(self, vector: np.ndarray) -> np.ndarray: + """L2 normalize.""" + norm = np.linalg.norm(vector) + if norm < 1e-7: + return vector + return vector / norm + + def get_adaptive_weights( + self, + quality_scores: Optional[Dict] = None + ) -> Dict[str, float]: + """Calculate adaptive weights based on quality.""" + if quality_scores is None: + return self.config['default_weights'].copy() + + adj = self.config['quality_adjustment'] + + blur_score = quality_scores.get('blur', {}).get('score', 0.5) + brightness_score = quality_scores.get('brightness', {}).get('score', 0.5) + pose_score = quality_scores.get('pose', {}).get('score', 0.5) + total_score = quality_scores.get('total_score', 0.5) + + if blur_score < 0.5: + return adj['high_blur'].copy() + elif brightness_score < 0.5: + return adj['bad_lighting'].copy() + elif pose_score < 0.6: + return adj['bad_pose'].copy() + elif total_score >= 0.8: + return adj['excellent'].copy() + else: + return self.config['default_weights'].copy() + + def fuse( + self, + lbph_features: np.ndarray, + cnn_features: np.ndarray, + quality_scores: Optional[Dict] = None + ) -> np.ndarray: + """ + Weighted concatenation fusion. + + Args: + lbph_features: LBPH vector (640-D) + cnn_features: CNN vector (512-D) + quality_scores: Quality assessment (optional) + + Returns: + Weighted, concatenated, L2 normalized vector + """ + # Normalize each branch + norm_lbph = self._l2_normalize(lbph_features) + norm_cnn = self._l2_normalize(cnn_features) + + # Get weights + weights = self.get_adaptive_weights(quality_scores) + + # Weighted concatenation + # Scale LBPH and CNN by their respective weights + scaled_lbph = norm_lbph * weights['lbph'] + scaled_cnn = norm_cnn * weights['mobilefacenet'] + + fused = np.concatenate([scaled_lbph, scaled_cnn]) + + # Final L2 normalize + return self._l2_normalize(fused) + + +def create_fusion(method: str = 'adaptive') -> object: + """ + Factory function to create fusion method. + + Args: + method: 'adaptive', 'weighted', or 'simple' + + Returns: + Fusion instance + """ + if method == 'adaptive': + return AdaptiveFusion(use_learned_fusion=True) + elif method == 'weighted': + return WeightedSimpleFusion() + else: # 'simple' + return SimpleFusion() + + +# Convenience function +def fuse_features( + lbph_features: np.ndarray, + cnn_features: np.ndarray, + quality_scores: Optional[Dict] = None, + method: str = 'adaptive' +) -> np.ndarray: + """ + Convenience function for feature fusion. + + Args: + lbph_features: LBPH vector (640-D) + cnn_features: CNN vector (512-D) + quality_scores: Quality assessment (optional) + method: 'adaptive', 'weighted', or 'simple' + + Returns: + Fused embedding vector + """ + fusion = create_fusion(method) + return fusion.fuse(lbph_features, cnn_features, quality_scores) + + +# Test function +if __name__ == "__main__": + log.info("Testing AdaptiveFusion...") + + # Create fusion + fusion = AdaptiveFusion(use_learned_fusion=True) + + # Simulate features + lbph = np.random.randn(LBPH_DIM).astype(np.float32) + cnn = np.random.randn(CNN_DIM).astype(np.float32) + + # Simulate quality scores + quality = { + 'blur': {'score': 0.8}, + 'brightness': {'score': 0.9}, + 'pose': {'score': 0.7}, + 'total_score': 0.8 + } + + # Test fusion + weights = fusion.get_adaptive_weights(quality) + log.info(f"Adaptive weights: {weights}") + + embedding = fusion.fuse(lbph, cnn, quality) + log.info(f"Embedding shape: {embedding.shape}") + log.info(f"Embedding norm: {np.linalg.norm(embedding):.4f}") + + # Test different quality scenarios + scenarios = [ + {'blur': {'score': 0.3}, 'brightness': {'score': 0.8}, 'pose': {'score': 0.8}, 'total_score': 0.5}, + {'blur': {'score': 0.9}, 'brightness': {'score': 0.2}, 'pose': {'score': 0.8}, 'total_score': 0.5}, + {'blur': {'score': 0.9}, 'brightness': {'score': 0.9}, 'pose': {'score': 0.9}, 'total_score': 0.9}, + ] + + log.info("\nWeight scenarios:") + for i, q in enumerate(scenarios): + w = fusion.get_adaptive_weights(q) + log.info(f" Scenario {i+1}: LBPH={w['lbph']:.2f}, CNN={w['mobilefacenet']:.2f}") diff --git a/facerecognition_presensi/ModelBaru/anti_spoof_manager.py b/facerecognition_presensi/ModelBaru/anti_spoof_manager.py new file mode 100644 index 0000000..b3071f2 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/anti_spoof_manager.py @@ -0,0 +1,519 @@ +""" +Anti-Spoofing Manager +===================== +Wrapper yang aman untuk integrasi Silent-Face-Anti-Spoofing ke dalam +sistem face recognition yang sudah ada. + +Desain: + - ZERO modifikasi ke file existing (enhanced_system.py, face_preprocessing.py, dll.) + - Graceful degradation: jika model .pth tidak ada atau PyTorch tidak terinstall, + sistem berjalan normal tanpa anti-spoofing (tidak crash). + - Lazy imports: semua import PyTorch dilakukan di dalam method, + bukan di level modul -- aman meski PyTorch belum terinstall. + - AntiSpoofGuardedSystem: wrapper komposisi atas EnhancedFaceRecognitionSystem. + File enhanced_system.py TIDAK disentuh sama sekali. + +Cara penggunaan: + # Opt-in -- hanya gunakan jika ingin anti-spoofing aktif + from anti_spoof_manager import AntiSpoofManager, AntiSpoofGuardedSystem + + mgr = AntiSpoofManager.from_config() # Baca dari ANTI_SPOOF_CONFIG + system = AntiSpoofGuardedSystem.create(mgr) # Wrapper atas EnhancedFaceRecognitionSystem + result = system.recognize(frame) # API identik dengan sistem lama + + # Atau tetap gunakan sistem lama tanpa anti-spoofing (tidak ada perubahan): + from enhanced_system import EnhancedFaceRecognitionSystem + system = EnhancedFaceRecognitionSystem() + result = system.recognize(frame) + +Author: Skripsi 2026 +""" + +import os +import sys +import logging +from pathlib import Path +from typing import Optional, Dict, List + +import cv2 +import numpy as np + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Konstanta internal +# --------------------------------------------------------------------------- +_PASSTHROUGH_RESULT = { + 'is_real': True, + 'real_prob': 1.0, + 'fake_prob': 0.0, + 'confidence': 1.0, + 'label': 'REAL', + 'passthrough': True, +} + + +# --------------------------------------------------------------------------- +# AntiSpoofManager +# --------------------------------------------------------------------------- + +class AntiSpoofManager: + """ + Manager untuk anti-spoofing detection menggunakan MiniFASNet. + + Graceful degradation: self.enabled = False jika setup gagal (tidak crash). + """ + + def __init__( + self, + model_dir: str, + spoof_lib_path: str, + default_model: str = "2.7_80x80_MiniFASNetV2.pth", + real_threshold: float = 0.5, + device: Optional[str] = None, + scale_factor: float = 2.7, + use_multi_model_voting: bool = False, + ): + self.model_dir = Path(model_dir) + self.spoof_lib_path = Path(spoof_lib_path) + self.default_model = default_model + self.real_threshold = real_threshold + self.scale_factor = scale_factor + self.use_multi_model_voting = use_multi_model_voting + + self.enabled = False + self._model = None + self._device = None + + self._setup(device) + + def _setup(self, device_hint: Optional[str]): + try: + import torch + _torch = torch + except ImportError: + log.warning("[AntiSpoof] PyTorch tidak tersedia. Anti-spoofing dinonaktifkan.") + return + + if not self.spoof_lib_path.exists(): + log.warning( + f"[AntiSpoof] SilentFaceAntiSpoofing tidak ditemukan di: {self.spoof_lib_path}\n" + " Clone: git clone https://github.com/minivision-ai/Silent-Face-Anti-Spoofing SilentFaceAntiSpoofing\n" + " Anti-spoofing dinonaktifkan." + ) + return + + if not self.model_dir.exists(): + log.warning( + f"[AntiSpoof] Folder model tidak ditemukan: {self.model_dir}\n" + " Anti-spoofing dinonaktifkan." + ) + return + + model_path = self.model_dir / self.default_model + if not model_path.exists(): + log.warning( + f"[AntiSpoof] Model tidak ditemukan: {model_path}\n" + " Download dari: https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/releases\n" + " Anti-spoofing dinonaktifkan." + ) + return + + lib_str = str(self.spoof_lib_path) + if lib_str not in sys.path: + sys.path.insert(0, lib_str) + + if device_hint is None: + self._device = 'cuda' if _torch.cuda.is_available() else 'cpu' + else: + self._device = device_hint + + try: + self._model = self._load_model(model_path, _torch) + self.enabled = True + log.info( + f"[AntiSpoof] Model dimuat: {self.default_model} " + f"(device={self._device}, threshold={self.real_threshold})" + ) + except Exception as e: + log.error(f"[AntiSpoof] Gagal memuat model: {e}\n Anti-spoofing dinonaktifkan.") + + def _load_model(self, model_path: Path, torch_module): + model_class = self._detect_model_class(model_path.name) + checkpoint = torch_module.load( + str(model_path), + map_location=self._device, + weights_only=False, + ) + model = model_class(conv6_kernel=(5, 5)).to(self._device) + + if isinstance(checkpoint, dict) and 'state_dict' in checkpoint: + state_dict = checkpoint['state_dict'] + else: + state_dict = checkpoint + + cleaned = { + (k[7:] if k.startswith('module.') else k): v + for k, v in state_dict.items() + } + model.load_state_dict(cleaned, strict=False) + model.eval() + return model + + @staticmethod + def _detect_model_class(filename: str): + try: + from src.model_lib.MiniFASNet import MiniFASNetV2, MiniFASNetV1SE + except ImportError as e: + raise ImportError( + f"Tidak dapat import MiniFASNet: {e}. " + "Pastikan SilentFaceAntiSpoofing ter-clone dengan benar." + ) from e + + fname_lower = filename.lower() + if 'v1se' in fname_lower or 'minifasnetv1se' in fname_lower: + return MiniFASNetV1SE + return MiniFASNetV2 + + def _crop_face_for_antispoof( + self, + image: np.ndarray, + bbox: tuple, + ) -> Optional[np.ndarray]: + if image is None or bbox is None: + return None + + x, y, w, h = bbox + h_img, w_img = image.shape[:2] + cx, cy = x + w // 2, y + h // 2 + new_w, new_h = int(w * self.scale_factor), int(h * self.scale_factor) + + x1 = max(0, cx - new_w // 2) + y1 = max(0, cy - new_h // 2) + x2 = min(w_img, cx + new_w // 2) + y2 = min(h_img, cy + new_h // 2) + + if x2 <= x1 or y2 <= y1: + return None + + face_crop = image[y1:y2, x1:x2] + return cv2.resize(face_crop, (80, 80)) if face_crop.size > 0 else None + + def _preprocess_tensor(self, face_80x80: np.ndarray): + import torch # lazy + face_rgb = cv2.cvtColor(face_80x80, cv2.COLOR_BGR2RGB) + face_norm = face_rgb.astype(np.float32) / 255.0 + return torch.from_numpy(face_norm).permute(2, 0, 1).unsqueeze(0).to(self._device) + + def predict(self, face_img: np.ndarray) -> Dict: + if not self.enabled or self._model is None: + return _PASSTHROUGH_RESULT.copy() + + try: + import torch # lazy + face_80x80 = cv2.resize(face_img, (80, 80)) + tensor = self._preprocess_tensor(face_80x80) + + with torch.no_grad(): + output = self._model(tensor) + + probs = torch.softmax(output, dim=1).cpu().numpy()[0] + fake_prob = float(probs[0]) + real_prob = float(probs[1]) + is_real = real_prob >= self.real_threshold + + result = { + 'is_real': is_real, + 'real_prob': real_prob, + 'fake_prob': fake_prob, + 'confidence': max(real_prob, fake_prob), + 'label': 'REAL' if is_real else 'FAKE', + 'passthrough': False, + } + log.debug(f"[AntiSpoof] {result['label']} (real={real_prob:.3f}, fake={fake_prob:.3f})") + return result + + except Exception as e: + log.warning(f"[AntiSpoof] Predict gagal (fallback REAL): {e}") + return _PASSTHROUGH_RESULT.copy() + + def predict_with_bbox(self, image: np.ndarray, bbox: tuple) -> Dict: + if not self.enabled: + return _PASSTHROUGH_RESULT.copy() + face_crop = self._crop_face_for_antispoof(image, bbox) + if face_crop is None: + return _PASSTHROUGH_RESULT.copy() + return self.predict(face_crop) + + def predict_multi_model(self, face_img: np.ndarray) -> Dict: + if not self.enabled: + return _PASSTHROUGH_RESULT.copy() + + try: + import torch # lazy + pth_files = sorted(self.model_dir.glob("*.pth")) + if not pth_files: + return self.predict(face_img) + + face_80x80 = cv2.resize(face_img, (80, 80)) + tensor = self._preprocess_tensor(face_80x80) + + model_results: List[Dict] = [] + vote_real = 0 + vote_fake = 0 + + for pth_path in pth_files: + try: + m = self._load_model(pth_path, torch) + with torch.no_grad(): + out = m(tensor) + probs = torch.softmax(out, dim=1).cpu().numpy()[0] + real_p = float(probs[1]) + fake_p = float(probs[0]) + is_real_vote = real_p >= self.real_threshold + model_results.append({ + 'model': pth_path.name, + 'real_prob': real_p, + 'fake_prob': fake_p, + 'label': 'REAL' if is_real_vote else 'FAKE', + }) + if is_real_vote: + vote_real += 1 + else: + vote_fake += 1 + del m + except Exception as e: + log.warning(f"[AntiSpoof] Model {pth_path.name} gagal: {e}") + + if not model_results: + return self.predict(face_img) + + is_real = vote_real > vote_fake + avg_real = float(np.mean([r['real_prob'] for r in model_results])) + avg_fake = float(np.mean([r['fake_prob'] for r in model_results])) + + return { + 'is_real': is_real, + 'real_prob': avg_real, + 'fake_prob': avg_fake, + 'confidence': max(avg_real, avg_fake), + 'label': 'REAL' if is_real else 'FAKE', + 'passthrough': False, + 'model_results': model_results, + 'vote_real': vote_real, + 'vote_fake': vote_fake, + } + + except Exception as e: + log.warning(f"[AntiSpoof] Multi-model predict gagal: {e}") + return _PASSTHROUGH_RESULT.copy() + + @classmethod + def from_config(cls) -> 'AntiSpoofManager': + try: + from config import ANTI_SPOOF_CONFIG as cfg + except ImportError: + log.warning("[AntiSpoof] Tidak dapat import ANTI_SPOOF_CONFIG.") + inst = object.__new__(cls) + inst.enabled = False + inst._model = None + inst._device = None + inst.model_dir = Path('') + inst.spoof_lib_path = Path('') + inst.default_model = '' + inst.real_threshold = 0.5 + inst.scale_factor = 2.7 + inst.use_multi_model_voting = False + return inst + + return cls( + model_dir=cfg['model_dir'], + spoof_lib_path=cfg['spoof_lib_path'], + default_model=cfg['default_model'], + real_threshold=cfg['real_threshold'], + device=cfg['device'], + scale_factor=cfg['scale_factor'], + use_multi_model_voting=cfg['use_multi_model_voting'], + ) + + def __repr__(self) -> str: + status = f"enabled, model={self.default_model}" if self.enabled else "disabled" + return f"AntiSpoofManager({status})" + + +# --------------------------------------------------------------------------- +# AntiSpoofGuardedSystem +# --------------------------------------------------------------------------- + +class AntiSpoofGuardedSystem: + """ + Wrapper komposisi atas EnhancedFaceRecognitionSystem dengan anti-spoofing. + + File enhanced_system.py TIDAK dimodifikasi. + API identik sehingga bisa digunakan sebagai drop-in replacement. + """ + + def __init__( + self, + base_system, + anti_spoof_manager: Optional[AntiSpoofManager] = None, + ): + self._base = base_system + self._anti_spoof = anti_spoof_manager + self._spoof_active = ( + anti_spoof_manager is not None and anti_spoof_manager.enabled + ) + + status = "AKTIF" if self._spoof_active else "TIDAK AKTIF" + log.info(f"[GuardedSystem] Anti-spoofing {status}") + + @classmethod + def create( + cls, + anti_spoof_manager: Optional[AntiSpoofManager] = None, + ) -> 'AntiSpoofGuardedSystem': + from enhanced_system import EnhancedFaceRecognitionSystem + base = EnhancedFaceRecognitionSystem() + if anti_spoof_manager is None: + anti_spoof_manager = AntiSpoofManager.from_config() + return cls(base, anti_spoof_manager) + + # ------------------------------------------------------------------ + # Core: recognize() dengan anti-spoof check + # ------------------------------------------------------------------ + + def recognize(self, frame: np.ndarray, update_template: bool = True): + """ + Recognize wajah dengan anti-spoofing check. + + Flow: + 1. Jika anti-spoof aktif: deteksi wajah, cek spoof via bbox + 2. Jika FAKE: return RecognitionResult(nim=None, similarity=0.0) + 3. Jika REAL / disabled: lanjut ke pipeline normal + """ + if self._spoof_active: + preproc = self._base.preprocessor.detect_and_preprocess( + frame, for_enrollment=False + ) + + if preproc['face'] is not None and preproc.get('bbox') is not None: + if self._anti_spoof.use_multi_model_voting: + spoof_result = self._anti_spoof.predict_multi_model(preproc['face']) + else: + spoof_result = self._anti_spoof.predict_with_bbox( + frame, preproc['bbox'] + ) + + if not spoof_result['is_real']: + log.info( + f"[GuardedSystem] Wajah FAKE terdeteksi " + f"(real={spoof_result['real_prob']:.3f}, " + f"fake={spoof_result['fake_prob']:.3f})" + ) + from faiss_gallery import RecognitionResult + return RecognitionResult( + nim=None, + similarity=0.0, + is_recognized=False, + ) + + return self._base.recognize(frame, update_template) + + # ------------------------------------------------------------------ + # Status + # ------------------------------------------------------------------ + + @property + def anti_spoof_active(self) -> bool: + return self._spoof_active + + def get_spoof_status(self) -> Dict: + return { + 'active': self._spoof_active, + 'model': self._anti_spoof.default_model if self._anti_spoof else None, + 'threshold': self._anti_spoof.real_threshold if self._anti_spoof else None, + 'device': self._anti_spoof._device if self._anti_spoof else None, + } + + # ------------------------------------------------------------------ + # Delegasi ke base system (transparent proxy) + # ------------------------------------------------------------------ + + def enroll(self, nim: str, frames, metadata=None) -> bool: + return self._base.enroll(nim, frames, metadata) + + def enroll_from_images(self, nim: str, image_paths, metadata=None) -> bool: + return self._base.enroll_from_images(nim, image_paths, metadata) + + def verify(self, frame: np.ndarray, nim: str): + return self._base.verify(frame, nim) + + def recognize_from_image(self, image_path: str, update_template: bool = True): + return self._base.recognize_from_image(image_path, update_template) + + def get_gallery_size(self) -> int: + return self._base.get_gallery_size() + + def list_identities(self): + return self._base.list_identities() + + def remove_identity(self, nim: str) -> bool: + return self._base.remove_identity(nim) + + def get_gallery_info(self) -> Dict: + return self._base.get_gallery_info() + + @property + def preprocessor(self): + return self._base.preprocessor + + @property + def calibrator(self): + return self._base.calibrator + + @property + def gallery(self): + return self._base.gallery + + def __repr__(self) -> str: + return ( + f"AntiSpoofGuardedSystem(" + f"base={self._base.__class__.__name__}, " + f"spoof={self._anti_spoof})" + ) + + +# --------------------------------------------------------------------------- +# Quick test / demo +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + print("=" * 60) + print("Anti-Spoofing Manager -- Quick Test") + print("=" * 60) + + print("\n[Test 1] Load AntiSpoofManager dari config...") + mgr = AntiSpoofManager.from_config() + print(f" {mgr}") + + print("\n[Test 2] Predict passthrough saat disabled...") + dummy = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) + result = mgr.predict(dummy) + assert result['is_real'] is True, "Passthrough harus REAL saat disabled" + print(f" is_real={result['is_real']}, passthrough={result.get('passthrough')}") + + print("\n[Test 3] Buat AntiSpoofGuardedSystem...") + try: + system = AntiSpoofGuardedSystem.create(anti_spoof_manager=mgr) + print(f" {system}") + print(f" Anti-spoof active: {system.anti_spoof_active}") + print(f" Gallery size: {system.get_gallery_size()}") + except Exception as e: + print(f" [!] GuardedSystem: {e}") + + print("\nDone! Untuk mengaktifkan anti-spoofing:") + print(" 1. Clone SilentFaceAntiSpoofing ke root proyek") + print(" 2. Letakkan .pth di ModelBaru/resources/anti_spoof_models/") + print(" 3. Set 'enabled': True di ANTI_SPOOF_CONFIG (config.py)") diff --git a/facerecognition_presensi/ModelBaru/calibrate_threshold.py b/facerecognition_presensi/ModelBaru/calibrate_threshold.py new file mode 100644 index 0000000..f13858b --- /dev/null +++ b/facerecognition_presensi/ModelBaru/calibrate_threshold.py @@ -0,0 +1,127 @@ +"""Calibrate ModelBaru's score-level threshold from a labelled holdout set. + +The calibration is zero-shot: it does not train a classifier. It only +measures the existing LBPH + MobileFaceNet fused score for genuine and +impostor comparisons, then selects an ROC/EER operating threshold. + +Dataset layout: + validation_root/ + NIM_1/query_01.jpg + NIM_2/query_01.jpg + +The validation images must be separate captures from the enrollment images. +""" + +import argparse +import logging +import sys +from pathlib import Path + +import cv2 +import numpy as np + +CURRENT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(CURRENT_DIR)) + +from enhanced_system import EnhancedFaceRecognitionSystem + + +IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.bmp', '.webp'} + + +def collect_scores(system, dataset_root: Path, limit_per_identity: int = 0): + genuine = [] + impostor = [] + skipped = 0 + + for identity_dir in sorted(dataset_root.iterdir()): + if not identity_dir.is_dir(): + continue + expected = identity_dir.name + if expected not in system.list_identities(): + logging.warning("Skipping %s: identity is not enrolled", expected) + continue + + image_paths = sorted( + p for p in identity_dir.iterdir() + if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS + ) + if limit_per_identity > 0: + image_paths = image_paths[:limit_per_identity] + + for image_path in image_paths: + image = cv2.imread(str(image_path)) + if image is None: + skipped += 1 + continue + + pre = system.preprocessor.detect_and_preprocess( + image, for_enrollment=False + ) + if pre['face'] is None: + skipped += 1 + continue + + face = pre['aligned_face'] if pre['aligned_face'] is not None else pre['face'] + lbph, cnn = system._extract_features(face) + lbph = lbph / (lbph.sum() + 1e-7) + cnn = cnn / (np.linalg.norm(cnn) + 1e-7) + quality = pre['quality']['quality_score'] if pre['quality'] else 0.5 + + # Score the same query against every enrolled identity. The + # expected identity is a genuine comparison; all others are + # impostor comparisons for the same real query. + for enrolled in system.list_identities(): + _, score = system.gallery.verify_identity( + enrolled, + cnn, + lbph, + quality_score=quality, + threshold=0.0, + ) + if enrolled == expected: + genuine.append(float(score)) + else: + impostor.append(float(score)) + + return genuine, impostor, skipped + + +def main(): + parser = argparse.ArgumentParser( + description='Kalibrasi threshold ROC/EER untuk ModelBaru' + ) + parser.add_argument('dataset', help='Folder validasi berisi subfolder per NIM') + parser.add_argument('--limit-per-identity', type=int, default=0) + args = parser.parse_args() + + root = Path(args.dataset).resolve() + if not root.is_dir(): + raise SystemExit(f'Dataset tidak ditemukan: {root}') + + logging.basicConfig(level=logging.INFO, format='%(message)s') + system = EnhancedFaceRecognitionSystem() + genuine, impostor, skipped = collect_scores( + system, root, args.limit_per_identity + ) + + print(f'Genuine scores : {len(genuine)}') + print(f'Impostor scores: {len(impostor)}') + print(f'Skipped : {skipped}') + + if len(genuine) < 10 or len(impostor) < 10: + raise SystemExit( + 'Data kalibrasi belum cukup. Sediakan minimal 10 genuine dan 10 impostor scores.' + ) + + result = system.calibrator.calibrate(genuine, impostor) + system.gallery.set_threshold(result.optimal_threshold) + print(f'Threshold : {result.optimal_threshold:.4f}') + print(f'FAR : {result.far_at_optimal:.4f}') + print(f'FRR : {result.frr_at_optimal:.4f}') + print(f'EER : {result.eer:.4f}') + print('Tersimpan : ModelBaru/models/threshold_calibration.json') + + +if __name__ == '__main__': + main() diff --git a/facerecognition_presensi/ModelBaru/config.py b/facerecognition_presensi/ModelBaru/config.py new file mode 100644 index 0000000..67af498 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/config.py @@ -0,0 +1,466 @@ +""" +Config - Face Recognition System (Revisi) +===================================== +Quality-Aware Score-Level Fusion Architecture + +Perubahan utama: +1. Hapus Dense Layer - langsung normalize dan fuse +2. Score-Level Fusion - similarity, bukan feature concatenation +3. Quality-Aware Weights: wCNN = QualityScore, wLBPH = 1 - QualityScore +4. Quality sederhana: Blur, Brightness, Pose (3 metrik) +5. Multi-frame: 15 → 5 terbaik +6. Hapus dynamic threshold adjustment +7. Incremental update: similarity ≥ 0.98 +8. Gallery Quality: simpan quality score, tanggal, jumlah registrasi +9. Top-3 (bukan Top-5) +10. MobileFaceNet → ArcFace Loss (penjelasan) + +Author: Skripsi 2026 +""" + +from pathlib import Path +import numpy as np + +# === PATH CONFIGURATION === +_CURRENT_DIR = Path(__file__).resolve().parent +MODEL_DIR = _CURRENT_DIR +FACE_MAHASISWA_DIR = MODEL_DIR / "face_mahasiswa" + +# === MODEL PATHS === +YUNET_MODEL_PATH = MODEL_DIR / "models" / "face_detection_yunet_2023mar.onnx" +MOBILEFACENET_MODEL_PATH = MODEL_DIR / "models" / "mobilefacenet.onnx" + +# === IMAGE SIZES === +IMG_SIZE_LBPH = 96 # Grayscale input for LBPH +IMG_SIZE_CNN = 112 # RGB input for MobileFaceNet +IMG_SIZE_ALIGN = 112 # For alignment reference + +# === LBPH PARAMETERS === +LBPH_RADIUS = 1 +LBPH_NEIGHBORS = 8 +LBPH_GRID_X = 8 +LBPH_GRID_Y = 8 +LBPH_DIM = (LBPH_NEIGHBORS + 2) * LBPH_GRID_X * LBPH_GRID_Y # 640 + +# === MOBILEFACENET PARAMETERS === +# MobileFaceNet menggunakan ArcFace Loss untuk training +# Output: 512-D face embedding yang discriminative +CNN_DIM = 512 + +# ============================================================================= +# 1. FACE ALIGNMENT CONFIG (YuNet 5 Landmarks) +# ============================================================================= +ALIGNMENT_CONFIG = { + 'enabled': True, + 'target_size': IMG_SIZE_ALIGN, + + # Reference points untuk frontal face (112x112 template) + # STANDARD InsightFace / ArcFace — WAJIB SAMA dengan training distribution + # Sumber: insightface/utils/face_align.py arcface_dst + # 5 landmark: left_eye, right_eye, nose, left_mouth, right_mouth + 'reference_points': np.array([ + # Official InsightFace/ArcFace 112x112 alignment template. + [38.2946, 51.6963], # left_eye + [73.5318, 51.5014], # right_eye + [56.0252, 71.7366], # nose + [41.5493, 92.3655], # left_mouth + [70.7299, 92.2041], # right_mouth + ], dtype=np.float64), + + 'reference_scale': 112.0, +} + +# ============================================================================= +# 2. FACE QUALITY ASSESSMENT (Sederhana - 3 Metrik) +# ============================================================================= +# Revisi: Hanya 3 metrik, semakin sedikit semakin mudah divalidasi +QUALITY_CONFIG = { + 'enabled': True, + + # Blur Detection (Variance of Laplacian) + # < 30 = blur (reject) + 'blur': { + 'threshold': 30.0, + }, + + # Brightness Assessment + # Revisi Low-Light: diturunkan dari min=40 → 20, max=220 → 230 + # Alasan: Di gedung jurusan indoor, mean brightness wajah bisa 20-45px. + # Threshold lama (40) terlalu ketat dan menolak wajah yang sebenarnya valid. + 'brightness': { + 'min': 20, # Revisi: 40 → 20 (toleransi kondisi indoor gelap) + 'max': 230, # Revisi: 220 → 230 (toleransi sedikit overexposed) + }, + + # Pose Estimation (dari landmark offset) + # Yaw/Pitch > 15° = reject + 'pose': { + 'yaw_threshold': 15.0, + 'pitch_threshold': 15.0, + }, + + # Pass threshold + 'pass_threshold': 0.5, # Minimal 2 dari 3 metrik harus bagus +} + +# ============================================================================= +# 3. SCORE-LEVEL FUSION CONFIG (BUKAN Feature-Level) +# ============================================================================= +# Revisi: Score-Level Fusion, bukan Feature-Level Fusion +# +# Arsitektur lama (Feature-Level): +# LBPH → Dense → BN → ReLU ─┐ +# ├─► Concat ─► L2Norm ─► Embedding +# CNN → Dense → BN → ReLU ─┘ +# +# Arsitektur baru (Score-Level): +# LBPH → Normalize ──────────────┐ +# ├─► Final Score = α·CNN_sim + β·LBPH_sim +# CNN → Normalize ──────────────┤ +# │ +# Quality → Weight Calculator ──┘ +# +# Keuntungan Score-Level: +# - Tidak perlu training Dense layer +# - Lebih mudah dijelaskan +# - Sering dipakai di sistem biometrik +# - Zero retraining +# +# Adaptive Fusion Weight (Revisi Cross-Condition): +# Masalah sebelumnya: CNN weight 70-90% terlalu dominan. +# Saat foto beda kamera/kondisi, CNN sim bisa turun ke 0.37 meski LBPH bagus (0.72). +# Solusi: buat weight adaptif berdasarkan CNN score aktual: +# - CNN > 0.75 (kondisi bagus, kamera sama): CNN 85%, LBPH 15% +# - CNN 0.55-0.75 (kondisi agak beda): CNN 65%, LBPH 35% +# - CNN < 0.55 (cross-camera/kondisi berbeda): CNN 50%, LBPH 50% +# +# Logika: jika CNN sendiri tidak yakin, beri LBPH lebih banyak suara. +FUSION_CONFIG = { + 'method': 'score_level', + 'use_quality_weighted': True, + + # Weight formula parameters (Revisi Cross-Condition: Adaptive CNN/LBPH) + # CNN weight sekarang adaptif berdasarkan CNN score aktual (bukan quality saja) + 'weight': { + 'base_cnn': 0.65, # Base CNN weight (turun dari 0.80) + 'slope': 0.10, # Slope adaptif berdasarkan quality + 'min_cnn': 0.50, # Minimal CNN 50% (turun dari 70%) — cross-condition mode + 'max_cnn': 0.85, # Maksimal CNN 85% (turun dari 90%) — normal mode + 'min_lbph': 0.15, # Minimal LBPH 15% + 'max_lbph': 0.50, # Maksimal LBPH 50% — bisa dominan saat CNN rendah + }, + + # Threshold CNN untuk memutuskan mode fusion + # Jika CNN sim < cross_condition_cnn_threshold → aktifkan LBPH rescue mode + 'cross_condition': { + 'enabled': True, + 'cnn_low_threshold': 0.55, # CNN di bawah ini dianggap cross-condition + 'cnn_high_threshold': 0.75, # CNN di atas ini dianggap kondisi bagus + # CNN lebih tahan terhadap perubahan pencahayaan daripada LBPH. + # Namun saat CNN rendah (cross-condition), LBPH harus bisa rescue. + # Sebelumnya w_cnn_low=0.70 masih terlalu tinggi → LBPH tidak efektif. + 'w_cnn_low': 0.55, # CNN rendah: lebih seimbang (55/45) + 'w_cnn_high': 0.85, # kondisi bagus: CNN dominan tapi tidak ekstrem + } +} + +# ============================================================================= +# 4. MULTI-FRAME EMBEDDING +# ============================================================================= +MULTIFRAME_CONFIG = { + 'capture_frames': 15, # Untuk webcam (15 frame video) + 'quality_pass_frames': 5, # Untuk webcam: ambil 5 terbaik + 'photo_mode_use_all': True, # Untuk enrollment foto: gunakan semua foto +} + +# ============================================================================= +# 5. THRESHOLD CONFIG (ROC + EER, tanpa dynamic adjustment) +# ============================================================================= +THRESHOLD_CONFIG = { + 'initial_threshold': 0.58, # Diturunkan ke 0.58 (dari 0.62) + # Alasan: test cross-condition menunjukkan + # fused score gelap/terang ekstrem di 0.58-0.61. + # 0.58 = titik yang aman: + # - Genuine cross-condition: ~0.58-0.97 ✓ + # - Impostor (orang lain): ~0.35-0.55 ✗ + + # Kalibrasi menggunakan ROC Curve dan EER + # Revisi: Hapus dynamic adjustment + # Quality sudah dipakai di fusion, tidak perlu di threshold + 'calibration': { + 'method': 'eer', + 'roc_points': 100, + }, +} + +# ============================================================================= +# 6. FAISS GALLERY CONFIG +# ============================================================================= +FAISS_CONFIG = { + 'index_type': 'IP', # Inner Product (cosine sim) + 'exact_threshold': 1000, # Use exact search untuk < 1000 + 'top_k': 3, # Revisi: Top-3 (bukan Top-5) + 'gallery_path': MODEL_DIR / "models" / "face_gallery_faiss", +} + +# ============================================================================= +# 7. INCREMENTAL TEMPLATE UPDATE +# ============================================================================= +# Revisi: Similarity ≥ 0.98 (lebih tinggi dari 0.90) +TEMPLATE_UPDATE_CONFIG = { + 'enabled': True, + 'similarity_threshold': 0.98, # Revisi: 0.98 (lebih konservatif) + + # Exponential Moving Average + 'ema_alpha': 0.2, # Weight untuk embedding baru +} + +# ============================================================================= +# 8. GALLERY QUALITY (Metadata untuk Template) +# ============================================================================= +# Revisi: Tambahkan Gallery Quality +# Saat menyimpan embedding, simpan juga: +# - Quality Score saat enrollment +# - Tanggal registrasi +# - Jumlah foto yang digunakan +# Berguna untuk memilih template terbaik nanti +GALLERY_QUALITY_CONFIG = { + 'enabled': True, + 'track_quality': True, + 'track_date': True, + 'track_count': True, +} + +# ============================================================================= +# 9. DATABASE PATHS +# ============================================================================= +DATABASE_CONFIG = { + 'faiss_index': MODEL_DIR / "models" / "face_index.faiss", + 'metadata': MODEL_DIR / "models" / "face_metadata.pkl", + 'calibration': MODEL_DIR / "models" / "threshold_calibration.json", + 'templates': MODEL_DIR / "models" / "face_templates.npy", +} + +# ============================================================================= +# FACE DETECTION CONFIG +# ============================================================================= +# Revisi Akurasi v3: Threshold dinaikkan untuk hindari false detection +DETECTION_CONFIG = { + 'face_confidence_threshold': 0.65, # Ditingkatkan dari 0.50 → pastikan hanya wajah jelas yang diproses + 'face_nms_threshold': 0.3, + 'face_max_faces': 5, + 'min_face_size': 80, # Ditingkatkan dari 50 → embedding wajah kecil tidak reliable + 'padding_ratio': 0.15, # Sedikit lebih besar untuk konteks +} + +# ============================================================================= +# PREPROCESSING CONFIG +# ============================================================================= +PREPROCESSING_CONFIG = { + 'clahe_clip_limit': 2.0, + 'clahe_tile_size': (8, 8), +} + +# ============================================================================= +# ENROLLMENT CONFIG +# ============================================================================= +ENROLLMENT_CONFIG = { + 'min_frames': 1, # Minimal 1 foto (fallback) + 'recommended_frames': 6, # Optimal: 6 foto per orang + 'min_quality_score': 0.5, # Minimum quality per foto + 'multi_template_cnn': True, # Simpan semua CNN templates individual + 'use_quality_weighted_centroid': True, # Centroid tertimbang kualitas +} + +# ============================================================================= +# SINGLE PHOTO ENROLLMENT CONFIG +# ============================================================================= +SINGLE_PHOTO_CONFIG = { + 'enabled': True, + 'min_quality_score': 0.6, # Single photo perlu quality score >= 0.6 + 'augmentation_versions': 10, # Generate 10 augmented versions untuk stabilitas + 'quality_weight_cnn': 0.75, # Higher CNN weight untuk quality yang bagus + 'quality_weight_lbph': 0.25, +} + +# ============================================================================= +# AUGMENTATION CONFIG +# ============================================================================= +AUGMENTATION_CONFIG = { + 'enabled': True, + 'enrollment_copies': 10, # Lebih banyak versi untuk single photo + 'max_combined': 3, # Max 3 augmentations sekaligus + 'methods': { + # CLAHE: clip limit lebih agresif untuk simulasi indoor + 'clahe': { + 'enabled': True, + 'clip_limit': (2.0, 4.0), # Revisi: (1.5,3.0) → (2.0,4.0) lebih agresif + 'tile_size': (8, 8), + 'probability': 0.8, # Revisi: 0.7 → 0.8 (lebih sering diterapkan) + }, + # Gamma: lebih ekstrem untuk simulasi pencahayaan indoor gelap + # Gamma < 1 → mencerahkan; Gamma > 1 → menggelapkan + 'gamma': { + 'enabled': True, + 'gamma_range': (0.4, 1.5), # Revisi: (0.7,1.3) → (0.4,1.5) simulasi indoor + 'probability': 0.7, # Revisi: 0.5 → 0.7 + }, + 'sharpen': { + 'enabled': True, + 'kernel_range': (0.5, 1.5), + 'probability': 0.4, + }, + 'hist_eq': { + 'enabled': True, + 'probability': 0.3, + }, + # Brightness: range lebih lebar untuk simulasi gelap-terang indoor + 'brightness': { + 'enabled': True, + 'factor_range': (0.45, 1.2), # Revisi: (0.85,1.15) → (0.45,1.2) simulasi indoor + 'probability': 0.7, # Revisi: 0.5 → 0.7 + }, + 'rotation': { + 'enabled': True, + 'max_angle': 8.0, + 'probability': 0.4, + }, + # Noise: lebih tinggi untuk simulasi grain kamera di kondisi gelap + 'noise': { + 'enabled': True, + 'std_range': (3.0, 12.0), # Revisi: (2.0,8.0) → (3.0,12.0) simulasi noise + 'probability': 0.5, # Revisi: 0.3 → 0.5 + }, + # Shadows: augmentasi bayangan/vignette — simulasi pencahayaan satu arah + 'shadows': { + 'enabled': True, + 'intensity_range': (0.3, 0.7), # Intensitas shadow: 30-70% area gelap + 'probability': 0.4, + }, + # Color Temperature: simulasi perbedaan lampu (tungsten warm vs neon cool) + # Kamera Android auto white-balance sering menghasilkan color cast + 'color_temp': { + 'enabled': True, + 'warm_shift': (10, 30), # Red boost, Blue reduce (lampu tungsten) + 'cool_shift': (10, 30), # Blue boost, Red reduce (lampu neon/TL) + 'probability': 0.5, + }, + # JPEG Compression: simulasi artefak kompresi kamera HP & WhatsApp + # Foto dari Android sering di-compress ulang sebelum dikirim ke server + 'jpeg_compression': { + 'enabled': True, + 'quality_range': (30, 70), # Quality rendah = artefak lebih banyak + 'probability': 0.4, + }, + # Gaussian Blur: simulasi sedikit out-of-focus dari kamera HP + 'gaussian_blur': { + 'enabled': True, + 'kernel_range': (3, 5), # Kernel kecil, blur ringan + 'probability': 0.3, + }, + # Exposure Simulation: simulasi auto-exposure kamera Android + # Kombinasi brightness + gamma + saturation shift yang realistis + 'exposure_simulation': { + 'enabled': True, + 'underexpose_range': (0.4, 0.7), # Gelap (indoor tanpa flash) + 'overexpose_range': (1.3, 1.8), # Terang (outdoor / flash) + 'probability': 0.5, + }, + } +} + +# ============================================================================= +# LEGACY SUPPORT +# ============================================================================= +FACE_GALLERY_PATH = MODEL_DIR / "models" / "face_gallery.pkl" +RECOGNITION_THRESHOLD = THRESHOLD_CONFIG['initial_threshold'] + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= +def get_lbph_dim(): + """Calculate LBPH feature dimension.""" + return LBPH_DIM + +def get_reference_points(): + """Get normalized reference points for face alignment.""" + return ALIGNMENT_CONFIG['reference_points'].copy() + +# ============================================================================= +# ANTI-SPOOFING CONFIG (Opsional — tidak mempengaruhi sistem existing) +# ============================================================================= +# Konfigurasi untuk AntiSpoofManager dan AntiSpoofGuardedSystem. +# +# PENTING: enabled=False by default. +# Sistem existing (EnhancedFaceRecognitionSystem) TIDAK membaca config ini. +# Hanya AntiSpoofGuardedSystem yang menggunakannya secara eksplisit. +# +# Cara mengaktifkan: +# from anti_spoof_manager import AntiSpoofManager, AntiSpoofGuardedSystem +# mgr = AntiSpoofManager.from_config() +# system = AntiSpoofGuardedSystem(anti_spoof_manager=mgr) +# +ANTI_SPOOF_CONFIG = { + # Aktifkan/nonaktifkan anti-spoofing. Default: False (tidak aktif). + 'enabled': False, + + # Folder tempat menyimpan file .pth model MiniFASNet. + # Buat folder ini dan letakkan file .pth di dalamnya secara manual. + 'model_dir': str(_CURRENT_DIR / "resources" / "anti_spoof_models"), + + # Path ke hasil clone SilentFaceAntiSpoofing repository. + # Clone di root proyek: git clone https://github.com/minivision-ai/Silent-Face-Anti-Spoofing SilentFaceAntiSpoofing + 'spoof_lib_path': str(_CURRENT_DIR.parent / "SilentFaceAntiSpoofing"), + + # Model default yang digunakan (ringan, ~0.4M params, ~1.6MB). + # Tersedia: 2.7_80x80_MiniFASNetV2.pth, 4_0_0_80x80_MiniFASNetV1SE.pth + 'default_model': "2.7_80x80_MiniFASNetV2.pth", + + # Threshold probability untuk klasifikasi "REAL". + # Turunkan (0.3-0.4) untuk lebih sensitif, naikkan (0.6-0.7) untuk lebih strict. + 'real_threshold': 0.5, + + # Device inferensi: None = auto-detect (CUDA jika ada, fallback CPU). + 'device': None, + + # Scale factor untuk crop wajah sebelum anti-spoofing. + # 2.7 sesuai dengan paper MiniFASNet (context wajah lebih lebar diperlukan). + 'scale_factor': 2.7, + + # Jika True, jalankan semua model .pth dan lakukan majority voting. + # Jika False, hanya gunakan default_model. + 'use_multi_model_voting': False, +} + + +def get_quality_weights(quality_score: float) -> dict: + """ + Calculate fusion weights based on quality score. + + Formula (Revisi - CNN dan LBPH Seimbang 50:50): + wCNN = clip(0.50 + 0.10(Q - 0.5), 0.40, 0.60) + wLBPH = 1 - wCNN + + Range: + - CNN: 40% - 60% (seimbang) + - LBPH: 40% - 60% (seimbang) + + Args: + quality_score: Q ∈ [0, 1] + + Returns: + Dictionary dengan w_lbph dan w_mobilefacenet + """ + cfg = FUSION_CONFIG['weight'] + + # wCNN = clip(base + slope * (Q - 0.5), min_cnn, max_cnn) + w_cnn = cfg['base_cnn'] + cfg['slope'] * (quality_score - 0.5) + w_cnn = max(cfg['min_cnn'], min(cfg['max_cnn'], w_cnn)) + + # wLBPH = 1 - wCNN + w_lbph = 1.0 - w_cnn + + return { + 'lbph': w_lbph, + 'mobilefacenet': w_cnn + } diff --git a/facerecognition_presensi/ModelBaru/enhanced_augmentation.py b/facerecognition_presensi/ModelBaru/enhanced_augmentation.py new file mode 100644 index 0000000..b5837ed --- /dev/null +++ b/facerecognition_presensi/ModelBaru/enhanced_augmentation.py @@ -0,0 +1,984 @@ +""" +Enhanced Data Augmentation Module +================================= +Enhanced augmentation methods for face recognition: +1. CLAHE (Contrast Limited Adaptive Histogram Equalization) +2. Gamma Correction +3. Sharpening +4. Histogram Equalization +5. Original methods (brightness, contrast, rotation, noise) + +These augmentations are moderate - not too extreme to preserve embedding quality. + +Author: Skripsi 2026 +""" + +import cv2 +import numpy as np +from typing import Tuple, List, Optional +import logging + +from config import AUGMENTATION_CONFIG + +log = logging.getLogger(__name__) + + +class EnhancedAugmentation: + """ + Enhanced face augmentation with multiple transformation methods. + + Unlike standard augmentations, these are designed to be moderate + and preserve the essential face features for recognition. + """ + + def __init__(self): + self.config = AUGMENTATION_CONFIG + self.methods = self.config['methods'] + + def apply_clahe(self, image: np.ndarray) -> np.ndarray: + """ + Apply CLAHE (Contrast Limited Adaptive Histogram Equalization). + + CLAHE is particularly useful for faces with poor lighting + as it enhances local contrast without over-amplifying noise. + + Args: + image: BGR or grayscale image + + Returns: + CLAHE-enhanced image + """ + cfg = self.methods['clahe'] + if not cfg.get('enabled', True): + return image + + # Convert to grayscale if needed (for LBPH) + if len(image.shape) == 3: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + else: + gray = image.copy() + + # Random clip limit + clip_limit = np.random.uniform(*cfg['clip_limit']) + + # Create CLAHE + clahe = cv2.createCLAHE( + clipLimit=clip_limit, + tileGridSize=cfg['tile_size'] + ) + + # Apply + enhanced = clahe.apply(gray) + + # Convert back to BGR if input was BGR + if len(image.shape) == 3: + enhanced = cv2.cvtColor(enhanced, cv2.COLOR_GRAY2BGR) + + return enhanced + + def apply_gamma(self, image: np.ndarray) -> np.ndarray: + """ + Apply Gamma correction. + + Gamma < 1: Brighten dark areas + Gamma > 1: Darken bright areas + + Args: + image: BGR or grayscale image + + Returns: + Gamma-corrected image + """ + cfg = self.methods['gamma'] + if not cfg.get('enabled', True): + return image + + gamma = np.random.uniform(*cfg['gamma_range']) + + # Build lookup table + inv_gamma = 1.0 / gamma + table = np.array([ + ((i / 255.0) ** inv_gamma) * 255 + for i in np.arange(0, 256) + ]).astype("uint8") + + # Apply + return cv2.LUT(image, table) + + def apply_sharpen(self, image: np.ndarray) -> np.ndarray: + """ + Apply sharpening filter. + + Uses an unsharp masking approach which enhances edges + without introducing artifacts. + + Args: + image: BGR or grayscale image + + Returns: + Sharpened image + """ + cfg = self.methods['sharpen'] + if not cfg.get('enabled', True): + return image + + strength = np.random.uniform(*cfg['kernel_range']) + + # Create sharpening kernel + # Standard sharpening kernel with adjustable strength + kernel = np.array([ + [0, -1, 0], + [-1, 4 + strength, -1], + [0, -1, 0] + ]) / (strength + 1) + + # Apply + if len(image.shape) == 3: + sharpened = cv2.filter2D(image, -1, kernel) + else: + sharpened = cv2.filter2D(image, -1, kernel) + + # Blend with original (0.3 = 30% sharpened, 70% original) + # This keeps the augmentation moderate + alpha = 0.3 + result = cv2.addWeighted( + image, 1 - alpha, + sharpened, alpha, + 0 + ) + + return result + + def apply_histogram_equalization(self, image: np.ndarray) -> np.ndarray: + """ + Apply global histogram equalization. + + This stretches the histogram to use the full dynamic range, + improving overall contrast. + + Note: CLAHE is generally preferred for faces as it preserves + local structure better. + + Args: + image: BGR or grayscale image + + Returns: + Histogram-equalized image + """ + cfg = self.methods['hist_eq'] + if not cfg.get('enabled', True): + return image + + if len(image.shape) == 3: + # Convert to YCrCb, equalize Y channel + ycrcb = cv2.cvtColor(image, cv2.COLOR_BGR2YCrCb) + ycrcb[:, :, 0] = cv2.equalizeHist(ycrcb[:, :, 0]) + return cv2.cvtColor(ycrcb, cv2.COLOR_YCrCb2BGR) + else: + return cv2.equalizeHist(image) + + def apply_brightness(self, image: np.ndarray) -> np.ndarray: + """ + Adjust brightness. + + Args: + image: BGR or grayscale image + + Returns: + Brightness-adjusted image + """ + cfg = self.methods['brightness'] + if not cfg.get('enabled', True): + return image + + factor = np.random.uniform(*cfg['factor_range']) + + if len(image.shape) == 3: + # For BGR, adjust each channel + hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + hsv[:, :, 2] = np.clip(hsv[:, :, 2].astype(np.float32) * factor, 0, 255).astype(np.uint8) + return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + else: + return np.clip(image.astype(np.float32) * factor, 0, 255).astype(np.uint8) + + def apply_rotation(self, image: np.ndarray) -> np.ndarray: + """ + Apply slight rotation. + + Rotation is kept small (±8°) to avoid drastic changes + to the embedding. + + Args: + image: BGR or grayscale image + + Returns: + Rotated image + """ + cfg = self.methods['rotation'] + if not cfg.get('enabled', True): + return image + + angle = np.random.uniform(-cfg['max_angle'], cfg['max_angle']) + h, w = image.shape[:2] + + # Get rotation matrix + M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) + + # Apply rotation + return cv2.warpAffine( + image, M, (w, h), + borderMode=cv2.BORDER_REFLECT, + borderValue=0 + ) + + def apply_noise(self, image: np.ndarray) -> np.ndarray: + """ + Add Gaussian noise. + + Args: + image: BGR or grayscale image + + Returns: + Noisy image + """ + cfg = self.methods.get('noise', {}) + if not cfg.get('enabled', True): + return image + + std = np.random.uniform(*cfg.get('std_range', (3.0, 12.0))) + + # Gaussian noise + noise = np.random.normal(0, std, image.shape) + noisy = image.astype(np.float32) + noise + + return np.clip(noisy, 0, 255).astype(np.uint8) + + def apply_shadows(self, image: np.ndarray) -> np.ndarray: + """ + Simulasi shadow / pencahayaan tidak merata (vignette satu sisi). + + Ini mensimulasi kondisi di gedung jurusan di mana cahaya datang dari + satu arah (jendela atau lampu overhead), menghasilkan bayangan gradien + pada separuh wajah. Augmentasi ini sangat penting untuk membuat gallery + embedding robust terhadap kondisi pencahayaan tidak merata. + + Args: + image: BGR or grayscale image + + Returns: + Image dengan shadow gradient + """ + cfg = self.methods.get('shadows', {}) + if not cfg.get('enabled', True): + return image + + intensity_range = cfg.get('intensity_range', (0.3, 0.7)) + intensity = np.random.uniform(*intensity_range) + + h, w = image.shape[:2] + + # Pilih arah shadow secara random: + # 0 = dari kiri, 1 = dari kanan, 2 = dari atas, 3 = dari bawah + direction = np.random.randint(0, 4) + + # Buat gradient mask (0.0 = shadow gelap, 1.0 = normal) + mask = np.ones((h, w), dtype=np.float32) + + shadow_strength = 1.0 - intensity # Misal intensity=0.5 → shadow=0.5 + + if direction == 0: # Shadow dari kiri + for col in range(w // 2): + alpha = shadow_strength + (1.0 - shadow_strength) * (col / (w / 2)) + mask[:, col] = alpha + elif direction == 1: # Shadow dari kanan + for col in range(w // 2, w): + alpha = shadow_strength + (1.0 - shadow_strength) * ((w - col) / (w / 2)) + mask[:, col] = alpha + elif direction == 2: # Shadow dari atas + for row in range(h // 2): + alpha = shadow_strength + (1.0 - shadow_strength) * (row / (h / 2)) + mask[row, :] = alpha + else: # Shadow dari bawah + for row in range(h // 2, h): + alpha = shadow_strength + (1.0 - shadow_strength) * ((h - row) / (h / 2)) + mask[row, :] = alpha + + # Apply mask + if len(image.shape) == 3: + mask_3ch = np.stack([mask, mask, mask], axis=2) + result = (image.astype(np.float32) * mask_3ch) + else: + result = (image.astype(np.float32) * mask) + + return np.clip(result, 0, 255).astype(np.uint8) + + def apply_color_temperature(self, image: np.ndarray, + temp: str = None) -> np.ndarray: + """ + Simulasi perbedaan color temperature pencahayaan. + + Kamera Android sering menghasilkan color cast yang berbeda + tergantung sumber cahaya: + - Warm (tungsten/incandescent): kekuningan, boost Red, reduce Blue + - Cool (neon/fluorescent/TL): kebiruan, boost Blue, reduce Red + + Args: + image: BGR image + temp: 'warm', 'cool', atau None (random) + + Returns: + Image dengan color temperature shift + """ + cfg = self.methods.get('color_temp', {}) + if not cfg.get('enabled', True): + return image + + if len(image.shape) != 3: + return image # Hanya untuk BGR + + if temp is None: + temp = np.random.choice(['warm', 'cool']) + + result = image.astype(np.float32) + + if temp == 'warm': + shift = np.random.uniform(*cfg.get('warm_shift', (10, 30))) + # BGR: boost Red (channel 2), reduce Blue (channel 0) + result[:, :, 2] = np.clip(result[:, :, 2] + shift, 0, 255) + result[:, :, 0] = np.clip(result[:, :, 0] - shift * 0.5, 0, 255) + else: # cool + shift = np.random.uniform(*cfg.get('cool_shift', (10, 30))) + # BGR: boost Blue (channel 0), reduce Red (channel 2) + result[:, :, 0] = np.clip(result[:, :, 0] + shift, 0, 255) + result[:, :, 2] = np.clip(result[:, :, 2] - shift * 0.5, 0, 255) + + return result.astype(np.uint8) + + def apply_jpeg_compression(self, image: np.ndarray, + quality: int = None) -> np.ndarray: + """ + Simulasi artefak kompresi JPEG dari kamera Android & WhatsApp. + + Foto dari HP sering di-compress beberapa kali: + 1. Kamera HP compress saat save (quality ~85) + 2. App/WhatsApp compress ulang saat kirim (quality ~60) + 3. Server bisa compress lagi saat simpan + + Ini menyebabkan artefak blok yang mengubah distribusi pixel. + + Args: + image: BGR image + quality: JPEG quality (1-100), lower = more artifacts + + Returns: + Image dengan JPEG compression artifacts + """ + cfg = self.methods.get('jpeg_compression', {}) + if not cfg.get('enabled', True): + return image + + if quality is None: + quality = int(np.random.uniform(*cfg.get('quality_range', (30, 70)))) + + # Encode ke JPEG buffer dan decode kembali + encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), quality] + _, encoded = cv2.imencode('.jpg', image, encode_param) + decoded = cv2.imdecode(encoded, cv2.IMREAD_COLOR) + + if decoded is None: + return image + + # Pastikan ukuran sama (seharusnya selalu sama) + if decoded.shape != image.shape: + decoded = cv2.resize(decoded, (image.shape[1], image.shape[0])) + + return decoded + + def apply_gaussian_blur(self, image: np.ndarray, + kernel_size: int = None) -> np.ndarray: + """ + Simulasi sedikit out-of-focus dari kamera HP. + + Kamera Android (terutama front camera) sering sedikit tidak fokus + saat autofocus gagal atau objek bergerak. + + Args: + image: BGR or grayscale image + kernel_size: Kernel size (harus ganjil: 3, 5) + + Returns: + Slightly blurred image + """ + cfg = self.methods.get('gaussian_blur', {}) + if not cfg.get('enabled', True): + return image + + if kernel_size is None: + k_range = cfg.get('kernel_range', (3, 5)) + kernel_size = np.random.choice( + [k for k in range(k_range[0], k_range[1] + 1) if k % 2 == 1] + ) + + return cv2.GaussianBlur(image, (kernel_size, kernel_size), 0) + + def apply_exposure_simulation(self, image: np.ndarray, + mode: str = None) -> np.ndarray: + """ + Simulasi auto-exposure kamera Android yang realistis. + + Berbeda dari simple brightness adjustment, ini mengkombinasikan: + - Brightness adjustment + - Gamma correction + - Sedikit saturation shift + + Ini lebih mendekati bagaimana kamera HP sebenarnya menghandle + exposure otomatis di berbagai kondisi. + + Args: + image: BGR image + mode: 'under' (indoor gelap), 'over' (outdoor terang), atau None (random) + + Returns: + Exposure-adjusted image + """ + cfg = self.methods.get('exposure_simulation', {}) + if not cfg.get('enabled', True): + return image + + if len(image.shape) != 3: + return image + + if mode is None: + mode = np.random.choice(['under', 'over']) + + result = image.copy() + + if mode == 'under': + # Underexposed: gelap + gamma tinggi + saturation turun + factor = np.random.uniform(*cfg.get('underexpose_range', (0.4, 0.7))) + gamma = np.random.uniform(1.2, 1.6) + + # Apply brightness + hsv = cv2.cvtColor(result, cv2.COLOR_BGR2HSV).astype(np.float32) + hsv[:, :, 2] *= factor + hsv[:, :, 1] *= 0.85 # Saturation turun sedikit saat gelap + hsv = np.clip(hsv, 0, 255).astype(np.uint8) + result = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + + # Apply gamma + inv_gamma = 1.0 / gamma + table = np.array([(i / 255.0) ** inv_gamma * 255 + for i in range(256)]).astype(np.uint8) + result = cv2.LUT(result, table) + else: + # Overexposed: terang + gamma rendah + saturation naik sedikit + factor = np.random.uniform(*cfg.get('overexpose_range', (1.3, 1.8))) + gamma = np.random.uniform(0.6, 0.85) + + # Apply brightness + hsv = cv2.cvtColor(result, cv2.COLOR_BGR2HSV).astype(np.float32) + hsv[:, :, 2] *= factor + hsv[:, :, 1] *= 1.1 # Saturation naik sedikit saat overexposed + hsv = np.clip(hsv, 0, 255).astype(np.uint8) + result = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + + # Apply gamma + inv_gamma = 1.0 / gamma + table = np.array([(i / 255.0) ** inv_gamma * 255 + for i in range(256)]).astype(np.uint8) + result = cv2.LUT(result, table) + + return result + + def augment( + self, + image: np.ndarray, + max_methods: int = None, + methods: List[str] = None + ) -> np.ndarray: + """ + Apply random augmentation combination. + + Args: + image: Input image (BGR or grayscale) + max_methods: Maximum number of methods to apply (default: config) + methods: Specific methods to apply (default: random selection) + + Returns: + Augmented image + """ + if not self.config.get('enabled', True): + return image + + max_methods = max_methods or self.config.get('max_combined', 3) + + # Select methods based on probability + if methods is None: + available_methods = [] + # Daftar semua augmentasi yang tersedia (termasuk yang baru) + all_aug_names = ['clahe', 'gamma', 'sharpen', 'hist_eq', + 'brightness', 'rotation', 'noise', 'shadows', + 'color_temp', 'jpeg_compression', + 'gaussian_blur', 'exposure_simulation'] + for name in all_aug_names: + cfg = self.methods.get(name, {}) + if cfg.get('enabled', True) and np.random.random() < cfg.get('probability', 0.5): + available_methods.append(name) + + # Randomly select up to max_methods + if len(available_methods) > max_methods: + methods = list(np.random.choice(available_methods, max_methods, replace=False)) + else: + methods = available_methods + + # Apply selected methods + augmented = image.copy() + for method in methods: + if method == 'clahe': + augmented = self.apply_clahe(augmented) + elif method == 'gamma': + augmented = self.apply_gamma(augmented) + elif method == 'sharpen': + augmented = self.apply_sharpen(augmented) + elif method == 'hist_eq': + augmented = self.apply_histogram_equalization(augmented) + elif method == 'brightness': + augmented = self.apply_brightness(augmented) + elif method == 'rotation': + augmented = self.apply_rotation(augmented) + elif method == 'noise': + augmented = self.apply_noise(augmented) + elif method == 'shadows': + augmented = self.apply_shadows(augmented) + elif method == 'color_temp': + augmented = self.apply_color_temperature(augmented) + elif method == 'jpeg_compression': + augmented = self.apply_jpeg_compression(augmented) + elif method == 'gaussian_blur': + augmented = self.apply_gaussian_blur(augmented) + elif method == 'exposure_simulation': + augmented = self.apply_exposure_simulation(augmented) + + return augmented + + def generate_versions( + self, + image: np.ndarray, + n_versions: int = None, + include_original: bool = True + ) -> List[np.ndarray]: + """ + Generate multiple augmented versions. + + Args: + image: Original image + n_versions: Number of augmented versions (default: config) + include_original: Include original in output + + Returns: + List of image versions (original + augmentations) + """ + n_versions = n_versions or self.config.get('enrollment_copies', 3) + + versions = [] + if include_original: + versions.append(image) + + for _ in range(n_versions): + versions.append(self.augment(image)) + + return versions + + def generate_lighting_variants( + self, + image: np.ndarray, + include_original: bool = False + ) -> List[np.ndarray]: + """ + Generate 7 kondisi cahaya deterministik untuk enrollment. + + Berbeda dari generate_versions() yang random, method ini + MENJAMIN gallery mencakup 7 skenario cahaya nyata: + 1. Terang (gamma 0.6, brightness 1.25) — outdoor / lampu kuat + 2. Gelap (gamma 1.5, brightness 0.6) — indoor sore + 3. Sangat gelap + CLAHE (brightness 0.4) — indoor malam + 4. Overexposed (brightness 1.5, sedikit saturasi) — flash HP + 5. Shadow kiri (cahaya dari kanan) — dekat jendela + 6. Shadow kanan (cahaya dari kiri) — dekat jendela sisi lain + 7. CLAHE agresif (normalisasi histogram) — kamera kualitas rendah + + Args: + image: Original BGR face crop + include_original: Apakah menyertakan gambar original + + Returns: + List of augmented images (7 atau 8 item) + """ + variants = [] + if include_original: + variants.append(image.copy()) + + h, w = image.shape[:2] + + # ── Variant 1: TERANG — Outdoor / lampu studio ────────────────────── + # Simulasi: foto enrollment diambil di luar ruangan atau dekat jendela + bright = image.astype(np.float32) + # Gamma < 1 → mencerahkan (inverse gamma correction) + gamma_table = np.array([(i / 255.0) ** (1.0 / 0.6) * 255 + for i in range(256)]).astype(np.uint8) + v1 = cv2.LUT(image, gamma_table) + v1 = cv2.convertScaleAbs(v1, alpha=1.0, beta=10) # sedikit offset + variants.append(np.clip(v1, 0, 255).astype(np.uint8)) + + # ── Variant 2: GELAP — Indoor sore ────────────────────────────────── + # Simulasi: ruang kelas di sore hari tanpa lampu cukup + gamma_table_dark = np.array([(i / 255.0) ** 1.5 * 255 + for i in range(256)]).astype(np.uint8) + v2 = cv2.LUT(image, gamma_table_dark) + hsv = cv2.cvtColor(v2, cv2.COLOR_BGR2HSV) + hsv[:, :, 2] = np.clip(hsv[:, :, 2].astype(np.float32) * 0.65, 0, 255).astype(np.uint8) + variants.append(cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)) + + # ── Variant 3: SANGAT GELAP + CLAHE ───────────────────────────────── + # Simulasi: indoor malam, kamera HP dengan auto-brightness + hsv3 = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + hsv3[:, :, 2] = np.clip(hsv3[:, :, 2].astype(np.float32) * 0.42, 0, 255).astype(np.uint8) + dark_face = cv2.cvtColor(hsv3, cv2.COLOR_HSV2BGR) + lab3 = cv2.cvtColor(dark_face, cv2.COLOR_BGR2LAB) + clahe_strong = cv2.createCLAHE(clipLimit=3.5, tileGridSize=(8, 8)) + lab3[:, :, 0] = clahe_strong.apply(lab3[:, :, 0]) + variants.append(cv2.cvtColor(lab3, cv2.COLOR_LAB2BGR)) + + # ── Variant 4: OVEREXPOSED ─────────────────────────────────────────── + # Simulasi: flash HP langsung, atau foto di luar ruangan tengah hari + hsv4 = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + hsv4[:, :, 2] = np.clip(hsv4[:, :, 2].astype(np.float32) * 1.55, 0, 255).astype(np.uint8) + v4 = cv2.cvtColor(hsv4, cv2.COLOR_HSV2BGR) + variants.append(np.clip(v4, 0, 255).astype(np.uint8)) + + # ── Variant 5: SHADOW KIRI ─────────────────────────────────────────── + # Simulasi: cahaya dari kanan (jendela sisi kanan), wajah kiri gelap + mask_left = np.ones((h, w), dtype=np.float32) + for col in range(w // 2): + alpha = 0.35 + 0.65 * (col / (w / 2)) + mask_left[:, col] = alpha + mask_left_3ch = np.stack([mask_left] * 3, axis=2) + v5 = np.clip(image.astype(np.float32) * mask_left_3ch, 0, 255).astype(np.uint8) + variants.append(v5) + + # ── Variant 6: SHADOW KANAN ────────────────────────────────────────── + # Simulasi: cahaya dari kiri (jendela sisi kiri), wajah kanan gelap + mask_right = np.ones((h, w), dtype=np.float32) + for col in range(w // 2, w): + alpha = 0.35 + 0.65 * ((w - col) / (w / 2)) + mask_right[:, col] = alpha + mask_right_3ch = np.stack([mask_right] * 3, axis=2) + v6 = np.clip(image.astype(np.float32) * mask_right_3ch, 0, 255).astype(np.uint8) + variants.append(v6) + + # ── Variant 7: CLAHE AGRESIF ───────────────────────────────────────── + # Simulasi: kamera kualitas rendah / foto lama — histogram sangat normalized + lab7 = cv2.cvtColor(image, cv2.COLOR_BGR2LAB) + clahe_agg = cv2.createCLAHE(clipLimit=4.5, tileGridSize=(4, 4)) + lab7[:, :, 0] = clahe_agg.apply(lab7[:, :, 0]) + v7 = cv2.cvtColor(lab7, cv2.COLOR_LAB2BGR) + variants.append(v7) + + return variants + + def generate_android_variants( + self, + image: np.ndarray, + include_original: bool = False + ) -> List[np.ndarray]: + """ + Generate 10 kondisi deterministik yang mensimulasi kamera Android. + + Method ini fokus pada kondisi yang TIDAK tercakup oleh + generate_lighting_variants, khususnya: + - Color temperature shift (warm/cool) + - JPEG compression artifacts + - Noise sensor di kondisi gelap + - Kombinasi realistis dari beberapa faktor sekaligus + + 10 varian: + 1. Indoor lampu neon (cool temp + gelap ringan) + 2. Indoor lampu tungsten (warm temp + shadow ringan) + 3. Outdoor teduh (gamma ringan + noise ringan + JPEG) + 4. Outdoor terang langsung (overexposed + slight warm + JPEG) + 5. Malam/gelap total (very dark + noise tinggi + CLAHE agresif) + 6. Backlight (shadow atas kuat + terang bawah) + 7. HP dekat jendela kiri (shadow kanan kuat + warm) + 8. HP dekat jendela kanan (shadow kiri kuat + cool) + 9. Foto terkompresi (JPEG q=25 + slight blur + noise) + 10. Kamera depan HP (flip + slight warm + noise + JPEG) + + Args: + image: Original BGR face crop + include_original: Apakah menyertakan gambar original + + Returns: + List of augmented images (10 atau 11 item) + """ + variants = [] + if include_original: + variants.append(image.copy()) + + h, w = image.shape[:2] + + # ── Variant 1: INDOOR LAMPU NEON ───────────────────────────────────── + # Lampu TL/neon menghasilkan cahaya kebiruan + sedikit gelap + v1 = image.astype(np.float32) + v1[:, :, 0] = np.clip(v1[:, :, 0] + 20, 0, 255) # Blue boost + v1[:, :, 2] = np.clip(v1[:, :, 2] - 10, 0, 255) # Red reduce + hsv1 = cv2.cvtColor(v1.astype(np.uint8), cv2.COLOR_BGR2HSV).astype(np.float32) + hsv1[:, :, 2] *= 0.80 # Gelap ringan + hsv1 = np.clip(hsv1, 0, 255).astype(np.uint8) + variants.append(cv2.cvtColor(hsv1, cv2.COLOR_HSV2BGR)) + + # ── Variant 2: INDOOR LAMPU TUNGSTEN ───────────────────────────────── + # Lampu pijar/tungsten = kekuningan + shadow ringan dari satu sisi + v2 = image.astype(np.float32) + v2[:, :, 2] = np.clip(v2[:, :, 2] + 25, 0, 255) # Red boost + v2[:, :, 0] = np.clip(v2[:, :, 0] - 15, 0, 255) # Blue reduce + v2[:, :, 1] = np.clip(v2[:, :, 1] + 5, 0, 255) # Green sedikit naik + # Shadow ringan dari kiri + mask2 = np.ones((h, w), dtype=np.float32) + for col in range(w // 3): + mask2[:, col] = 0.6 + 0.4 * (col / (w / 3)) + mask2_3ch = np.stack([mask2] * 3, axis=2) + v2 = np.clip(v2 * mask2_3ch, 0, 255).astype(np.uint8) + variants.append(v2) + + # ── Variant 3: OUTDOOR TEDUH ───────────────────────────────────────── + # Teduh = cahaya diffuse, sedikit noise, JPEG dari share HP + gamma_table = np.array([(i / 255.0) ** 0.85 * 255 + for i in range(256)]).astype(np.uint8) + v3 = cv2.LUT(image, gamma_table) + # Noise ringan + noise3 = np.random.normal(0, 5, v3.shape).astype(np.float32) + v3 = np.clip(v3.astype(np.float32) + noise3, 0, 255).astype(np.uint8) + # JPEG compression + _, enc3 = cv2.imencode('.jpg', v3, [cv2.IMWRITE_JPEG_QUALITY, 55]) + v3 = cv2.imdecode(enc3, cv2.IMREAD_COLOR) + if v3 is not None: + variants.append(v3) + else: + variants.append(image.copy()) + + # ── Variant 4: OUTDOOR TERANG LANGSUNG ─────────────────────────────── + # Sinar matahari langsung = overexposed + slight warm + JPEG + hsv4 = cv2.cvtColor(image, cv2.COLOR_BGR2HSV).astype(np.float32) + hsv4[:, :, 2] = np.clip(hsv4[:, :, 2] * 1.6, 0, 255) + hsv4[:, :, 1] = np.clip(hsv4[:, :, 1] * 1.1, 0, 255) # Sedikit saturasi + v4 = cv2.cvtColor(np.clip(hsv4, 0, 255).astype(np.uint8), cv2.COLOR_HSV2BGR) + # Slight warm (matahari = warm) + v4 = v4.astype(np.float32) + v4[:, :, 2] = np.clip(v4[:, :, 2] + 10, 0, 255) + v4 = v4.astype(np.uint8) + # JPEG + _, enc4 = cv2.imencode('.jpg', v4, [cv2.IMWRITE_JPEG_QUALITY, 50]) + v4 = cv2.imdecode(enc4, cv2.IMREAD_COLOR) + if v4 is not None: + variants.append(v4) + else: + variants.append(image.copy()) + + # ── Variant 5: MALAM / GELAP TOTAL ─────────────────────────────────── + # Kamera HP di malam hari: sangat gelap + noise tinggi + auto-CLAHE + hsv5 = cv2.cvtColor(image, cv2.COLOR_BGR2HSV).astype(np.float32) + hsv5[:, :, 2] *= 0.30 # Sangat gelap + hsv5[:, :, 1] *= 0.7 # Saturation turun + v5 = cv2.cvtColor(np.clip(hsv5, 0, 255).astype(np.uint8), cv2.COLOR_HSV2BGR) + # Noise tinggi (sensor noise di low light) + noise5 = np.random.normal(0, 15, v5.shape).astype(np.float32) + v5 = np.clip(v5.astype(np.float32) + noise5, 0, 255).astype(np.uint8) + # CLAHE agresif (simulasi auto-brightness HP) + lab5 = cv2.cvtColor(v5, cv2.COLOR_BGR2LAB) + clahe5 = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(8, 8)) + lab5[:, :, 0] = clahe5.apply(lab5[:, :, 0]) + variants.append(cv2.cvtColor(lab5, cv2.COLOR_LAB2BGR)) + + # ── Variant 6: BACKLIGHT ───────────────────────────────────────────── + # Cahaya dari belakang subjek: bagian atas terang, bawah gelap + mask6 = np.ones((h, w), dtype=np.float32) + for row in range(h): + # Gradien: atas terang (1.3), tengah normal (1.0), bawah gelap (0.5) + if row < h // 3: + mask6[row, :] = 1.3 - 0.3 * (row / (h / 3)) + elif row > 2 * h // 3: + mask6[row, :] = 1.0 - 0.5 * ((row - 2 * h // 3) / (h / 3)) + mask6_3ch = np.stack([mask6] * 3, axis=2) + v6 = np.clip(image.astype(np.float32) * mask6_3ch, 0, 255).astype(np.uint8) + variants.append(v6) + + # ── Variant 7: HP DEKAT JENDELA KIRI ───────────────────────────────── + # Cahaya kuat dari kiri: wajah kanan sangat gelap + warm tint + mask7 = np.ones((h, w), dtype=np.float32) + for col in range(w): + if col > w // 3: + mask7[:, col] = 1.0 - 0.6 * ((col - w // 3) / (2 * w / 3)) + mask7_3ch = np.stack([mask7] * 3, axis=2) + v7 = np.clip(image.astype(np.float32) * mask7_3ch, 0, 255) + v7[:, :, 2] = np.clip(v7[:, :, 2] + 10, 0, 255) # Warm tint + variants.append(v7.astype(np.uint8)) + + # ── Variant 8: HP DEKAT JENDELA KANAN ──────────────────────────────── + # Cahaya kuat dari kanan: wajah kiri sangat gelap + cool tint + mask8 = np.ones((h, w), dtype=np.float32) + for col in range(w): + if col < 2 * w // 3: + mask8[:, col] = 0.4 + 0.6 * (col / (2 * w / 3)) + mask8_3ch = np.stack([mask8] * 3, axis=2) + v8 = np.clip(image.astype(np.float32) * mask8_3ch, 0, 255) + v8[:, :, 0] = np.clip(v8[:, :, 0] + 10, 0, 255) # Cool tint + variants.append(v8.astype(np.uint8)) + + # ── Variant 9: FOTO TERKOMPRESI ────────────────────────────────────── + # Foto yang sudah di-share berkali-kali: heavy JPEG + blur + noise + v9 = cv2.GaussianBlur(image, (3, 3), 0) + noise9 = np.random.normal(0, 8, v9.shape).astype(np.float32) + v9 = np.clip(v9.astype(np.float32) + noise9, 0, 255).astype(np.uint8) + _, enc9 = cv2.imencode('.jpg', v9, [cv2.IMWRITE_JPEG_QUALITY, 25]) + v9 = cv2.imdecode(enc9, cv2.IMREAD_COLOR) + if v9 is not None: + variants.append(v9) + else: + variants.append(image.copy()) + + # ── Variant 10: KAMERA DEPAN HP ────────────────────────────────────── + # Front camera = mirrored + slight warm + noise + JPEG + v10 = cv2.flip(image, 1) # Horizontal flip (mirror) + v10 = v10.astype(np.float32) + v10[:, :, 2] = np.clip(v10[:, :, 2] + 8, 0, 255) # Slight warm + v10[:, :, 0] = np.clip(v10[:, :, 0] - 5, 0, 255) + noise10 = np.random.normal(0, 6, v10.shape).astype(np.float32) + v10 = np.clip(v10 + noise10, 0, 255).astype(np.uint8) + _, enc10 = cv2.imencode('.jpg', v10, [cv2.IMWRITE_JPEG_QUALITY, 60]) + v10 = cv2.imdecode(enc10, cv2.IMREAD_COLOR) + if v10 is not None: + variants.append(v10) + else: + variants.append(image.copy()) + + return variants + + +# Backward compatibility with old Augmentation class +class Augmentation(EnhancedAugmentation): + """ + Backward compatibility wrapper. + Uses the new EnhancedAugmentation class. + """ + + def __init__(self): + super().__init__() + + @staticmethod + def random_brightness(image, factor_range=(0.8, 1.2)): + """Legacy method - use enhanced version.""" + cfg = {'factor_range': factor_range, 'enabled': True} + result = image.copy() + factor = np.random.uniform(*factor_range) + if len(image.shape) == 3: + hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + hsv[:, :, 2] = np.clip(hsv[:, :, 2].astype(np.float32) * factor, 0, 255).astype(np.uint8) + result = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + else: + result = np.clip(image.astype(np.float32) * factor, 0, 255).astype(np.uint8) + return result + + @staticmethod + def random_contrast(image, factor_range=(0.8, 1.2)): + """Legacy method.""" + factor = np.random.uniform(*factor_range) + mean = image.mean() + return np.clip((image - mean) * factor + mean, 0, 255).astype(np.uint8) + + @staticmethod + def random_rotation(image, max_angle=10): + """Legacy method.""" + angle = np.random.uniform(-max_angle, max_angle) + h, w = image.shape[:2] + M = cv2.getRotationMatrix2D((w/2, h/2), angle, 1.0) + return cv2.warpAffine(image, M, (w, h), borderMode=cv2.BORDER_REPLICATE) + + @staticmethod + def random_noise(image, std=5.0): + """Legacy method.""" + noise = np.random.normal(0, std, image.shape) + return np.clip(image.astype(np.float32) + noise, 0, 255).astype(np.uint8) + + @classmethod + def augment(cls, image): + """Legacy method.""" + aug = cls() + return aug.augment(image) + + @classmethod + def generate_versions(cls, image, n=4): + """Legacy method.""" + aug = cls() + return aug.generate_versions(image, n_versions=n, include_original=True) + + +def augment_face( + face: np.ndarray, + method: str = 'random', + seed: Optional[int] = None +) -> np.ndarray: + """ + Convenience function for face augmentation. + + Args: + face: Face crop + method: Augmentation method ('clahe', 'gamma', 'sharpen', 'hist_eq', 'random') + seed: Random seed for reproducibility + + Returns: + Augmented face + """ + if seed is not None: + np.random.seed(seed) + + aug = EnhancedAugmentation() + + if method == 'clahe': + return aug.apply_clahe(face) + elif method == 'gamma': + return aug.apply_gamma(face) + elif method == 'sharpen': + return aug.apply_sharpen(face) + elif method == 'hist_eq': + return aug.apply_histogram_equalization(face) + else: # 'random' + return aug.augment(face) + + +# Test function +if __name__ == "__main__": + import sys + + log.info("Testing EnhancedAugmentation...") + + # Test with sample image + if len(sys.argv) > 1: + image = cv2.imread(sys.argv[1]) + if image is not None: + aug = EnhancedAugmentation() + + # Generate versions + versions = aug.generate_versions(image, n_versions=4) + log.info(f"Generated {len(versions)} versions") + + # Save examples + for i, v in enumerate(versions): + cv2.imwrite(f"aug_test_{i}.jpg", v) + log.info("Saved augmented versions") + else: + # Synthetic test + image = np.random.randint(0, 255, (112, 112, 3), dtype=np.uint8) + aug = EnhancedAugmentation() + + versions = aug.generate_versions(image, n_versions=3) + log.info(f"Generated {len(versions)} versions") diff --git a/facerecognition_presensi/ModelBaru/enhanced_system.py b/facerecognition_presensi/ModelBaru/enhanced_system.py new file mode 100644 index 0000000..bdd00ba --- /dev/null +++ b/facerecognition_presensi/ModelBaru/enhanced_system.py @@ -0,0 +1,847 @@ +""" +Enhanced Face Recognition System (Revisi) +===================================== +Quality-Aware Score-Level Fusion Architecture + +Arsitektur Final: +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ INPUT IMAGE │ +│ │ │ +│ ▼ │ +│ Face Detection (YuNet) │ +│ │ │ +│ ▼ │ +│ Face Quality Assessment (3 metrik: Blur, Brightness, Pose) │ +│ │ │ +│ ▼ │ +│ Face Alignment (5 Landmark) │ +│ │ │ +│ ├──► LBPH Extractor ──► LBPH Features (640-D) │ +│ │ │ │ +│ │ │ │ +│ ├──► CNN Extractor ──► CNN Embedding (512-D) │ +│ │ │ │ +│ │ ┌───────────────┴───────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ SCORE-LEVEL FUSION │ │ +│ │ │ │ +│ │ LBPH Similarity = dot(Query_LBPH, Gallery_LBPH) │ │ +│ │ CNN Similarity = dot(Query_CNN, Gallery_CNN) │ │ +│ │ │ │ +│ │ wCNN = QualityScore │ │ +│ │ wLBPH = 1 - QualityScore │ │ +│ │ │ │ +│ │ Final = wCNN × CNN_sim + wLBPH × LBPH_sim │ │ +│ │ │ │ +│ └────────────────────────────┬────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Threshold (ROC + EER) │ +│ │ │ +│ ┌──────────┴──────────┐ │ +│ ▼ ▼ │ +│ Recognized Unknown │ +│ │ │ +│ ▼ │ +│ Incremental Update (sim ≥ 0.98) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Revisi: +1. Score-Level Fusion (bukan Feature-Level) +2. Quality-Aware Weights: wCNN = QualityScore +3. 3 metrik quality (Blur, Brightness, Pose) +4. Hapus Dense Layer +5. Multi-frame: 15 → 5 terbaik +6. Top-3 (bukan Top-5) +7. Gallery Quality metadata +8. Incremental update: sim ≥ 0.98 + +Author: Skripsi 2026 +""" + +import cv2 +import numpy as np +import logging +from typing import List, Dict, Tuple +from pathlib import Path + +from config import ( + MOBILEFACENET_MODEL_PATH, + IMG_SIZE_LBPH, IMG_SIZE_CNN, + MULTIFRAME_CONFIG, ENROLLMENT_CONFIG +) + +from face_preprocessing import FacePreprocessor, FaceQualityAssessment +from enhanced_augmentation import EnhancedAugmentation +from faiss_gallery import FAISSGallery, RecognitionResult +from threshold_calibrator import ThresholdCalibrator + +log = logging.getLogger(__name__) + + +class LBPHExtractor: + """LBPH Feature Extractor.""" + + def __init__(self): + self.grid_x = 8 + self.grid_y = 8 + self.neighbors = 8 + self.radius = 1 + self.n_bins = self.neighbors + 2 + self._clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + # CLAHE agresif untuk kondisi indoor gelap + self._clahe_aggressive = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(8, 8)) + + def extract(self, face: np.ndarray) -> np.ndarray: + """Extract LBPH features from face.""" + face_lbph = cv2.resize(face, (IMG_SIZE_LBPH, IMG_SIZE_LBPH)) + + if len(face_lbph.shape) == 3: + gray = cv2.cvtColor(face_lbph, cv2.COLOR_BGR2GRAY) + else: + gray = face_lbph + + # Gunakan CLAHE agresif jika kondisi gelap (mean < 60) + mean_brightness = float(np.mean(gray)) + clahe_op = self._clahe_aggressive if mean_brightness < 60 else self._clahe + enhanced = clahe_op.apply(gray) + lbp = self._compute_lbp(enhanced) + + h, w = lbp.shape + cell_h, cell_w = h // self.grid_y, w // self.grid_x + + histograms = [] + for gy in range(self.grid_y): + for gx in range(self.grid_x): + y1, y2 = gy * cell_h, (gy + 1) * cell_h + x1, x2 = gx * cell_w, (gx + 1) * cell_w + cell = lbp[y1:y2, x1:x2] + + hist, _ = np.histogram(cell, bins=self.n_bins, range=(0, self.n_bins)) + hist = hist.astype(np.float32) + hist /= (hist.sum() + 1e-7) + histograms.append(hist) + + return np.concatenate(histograms) + + def _compute_lbp(self, gray: np.ndarray) -> np.ndarray: + """Compute uniform LBP codes (vectorized — stabil & cepat). + + Mengganti pixel-loop lama yang punya float-rounding inconsistency + dengan implementasi vectorized numpy: + - Tidak ada int(round(sin/cos)) per-pixel → tidak ada rounding drift + - Semua 8 neighbors di-shift sekaligus → reproducible untuk gambar sama + - ~50x lebih cepat dari loop Python + """ + h, w = gray.shape + padded = np.pad(gray, self.radius, mode='edge').astype(np.int16) + + # Pre-compute tetap offset untuk 8 neighbors (radius=1, neighbors=8) + # Urutan: mulai dari kanan (0°), searah jarum jam + offsets = [ + (0, 1), # 0° East + (-1, 1), # 45° NE + (-1, 0), # 90° North + (-1, -1), # 135° NW + (0, -1), # 180° West + (1, -1), # 225° SW + (1, 0), # 270° South + (1, 1), # 315° SE + ] + + center = padded[self.radius:self.radius + h, + self.radius:self.radius + w] + + # Stack 8 shifted neighbor planes + neighbors_stack = [] + for di, dj in offsets: + r0 = self.radius + di + r1 = self.radius + di + h + c0 = self.radius + dj + c1 = self.radius + dj + w + neighbors_stack.append(padded[r0:r1, c0:c1]) + + # Binary bits: neighbor >= center + bits = np.stack([(n >= center).astype(np.uint8) + for n in neighbors_stack], axis=0) # (8, H, W) + + # Count transitions untuk uniform LBP + # transitions[k] = bits[k] XOR bits[(k+1)%8] + transitions = np.zeros((h, w), dtype=np.uint8) + for k in range(self.neighbors): + transitions += (bits[k] != bits[(k + 1) % self.neighbors]).astype(np.uint8) + + # Sum of bits (value 0-8 untuk uniform, 9 untuk non-uniform) + bit_sum = bits.sum(axis=0).astype(np.uint8) # (H, W) + + # Uniform: transitions <= 2 → code = bit_sum (0-8) + # Non-uniform: transitions > 2 → code = neighbors + 1 = 9 + lbp = np.where(transitions <= 2, bit_sum, + np.uint8(self.neighbors + 1)) + + return lbp.astype(np.uint8) + + +class MobileFaceNetExtractor: + """ + MobileFaceNet Feature Extractor. + + MobileFaceNet adalah lightweight face recognition network yang di-train + dengan ArcFace Loss. ArcFace Loss memberikan margin angular yang besar + untuk meningkatkan separabilitas antar kelas. + + Arsitektur: + - Input: 112×112 RGB face crop + - Preprocessing: (pixel - 127.5) / 127.5 → [-1, 1] + - Backbone: MobileNetV2-style depthwise separable convolutions + - Head: ArcFace margin-based softmax + - Output: 512-D discriminative face embedding + + Model pretrained:buffalo_l dari insightface + (https://github.com/deepinsight/insightface) + + Referensi: + - MobileFaceNet: https://arxiv.org/abs/1804.07573 + - ArcFace: https://arxiv.org/abs/1801.07698 + """ + + def __init__(self): + import onnxruntime as ort + + model_path = str(MOBILEFACENET_MODEL_PATH) + + if not Path(model_path).exists(): + log.info("[MobileFaceNet] Model not found") + self._session = None + self._input_name = None + return + + self._session = ort.InferenceSession( + model_path, + providers=['CPUExecutionProvider'] + ) + self._input_name = self._session.get_inputs()[0].name + log.info("[MobileFaceNet] Loaded (ArcFace pretrained)") + + def extract(self, face: np.ndarray) -> np.ndarray: + """Extract face embedding dari face crop. + + Revisi Low-Light: + Ditambahkan CLAHE di LAB colorspace sebelum normalisasi. + LAB memisahkan luminance (L) dari chrominance (A,B), + sehingga CLAHE hanya memperkuat kecerahan tanpa mengubah warna. + Ini sangat membantu di kondisi indoor gelap karena CNN mengharapkan + distribusi pixel yang cukup terang untuk menghasilkan embedding yang stabil. + """ + if self._session is None: + raise RuntimeError( + f"MobileFaceNet tidak berhasil dimuat: {model_path}. " + "Periksa file ONNX dan instalasi onnxruntime." + ) + + face_cnn = cv2.resize(face, (IMG_SIZE_CNN, IMG_SIZE_CNN)) + + # ───────────────────────────────────────────────────────────────────── + # NORMALISASI PENCAHAYAAN: CLAHE di LAB colorspace — SELALU AKTIF + # + # Perubahan dari kondisional (brightness<80) menjadi always-on: + # Alasan: Foto enrollment sering diambil di kondisi terang (luar ruangan + # atau lampu studio), sedangkan recognition terjadi di dalam ruangan gelap. + # Jika normalisasi HANYA aktif untuk gambar gelap, dua gambar orang yang + # sama punya distribusi pixel sangat berbeda → CNN similarity turun ke 0.56. + # Dengan always-on CLAHE (clip ringan untuk gambar terang), distribusi + # dinormalisasi di kedua sisi → CNN similarity stabil di 0.80+. + # + # Clip adaptif: + # < 40 (sangat gelap): clip=3.0 — agresif, naikkan kontrasl lokal + # < 80 (gelap) : clip=2.0 — moderat + # >= 80 (normal/terang): clip=1.5 — ringan, normalisasi saja + # ───────────────────────────────────────────────────────────────────── + # Keep the input distribution expected by ArcFace/MobileFaceNet: + # aligned face -> RGB -> [-1, 1]. The previous Tan-Triggs + CLAHE + # chain was too aggressive and reduced cross-camera similarity. + face_cnn = cv2.cvtColor(face_cnn, cv2.COLOR_BGR2RGB) + face_cnn = face_cnn.astype(np.float32) + + # Normalize to [-1, 1] + face_cnn = (face_cnn - 127.5) / 127.5 + + # NCHW format + face_cnn = np.transpose(face_cnn, (2, 0, 1)) + face_cnn = np.expand_dims(face_cnn, axis=0) + + features = self._session.run(None, {self._input_name: face_cnn})[0][0] + features = np.asarray(features, dtype=np.float32).reshape(-1) + norm = np.linalg.norm(features) + if norm < 1e-7: + raise RuntimeError("MobileFaceNet menghasilkan embedding kosong") + return features / norm + + +class EnhancedFaceRecognitionSystem: + """ + Enhanced Face Recognition System dengan Score-Level Fusion. + + Novelty: Quality-Aware Score-Level Fusion + """ + + def __init__(self): + log.info("[System] Initializing Face Recognition System (Score-Level Fusion)...") + + # 1. Preprocessing (Alignment + Quality) + self.preprocessor = FacePreprocessor() + self.quality = FaceQualityAssessment() + + # 2. Feature Extractors + self.lbph_extractor = LBPHExtractor() + self.mobile_extractor = MobileFaceNetExtractor() + + # 3. Gallery + self.gallery = FAISSGallery(embedding_dim=512) + + # 4. Threshold Calibrator + self.calibrator = ThresholdCalibrator() + + # 5. Augmentation + self.augmentation = EnhancedAugmentation() + + log.info("[System] Initialization complete") + + def _extract_features( + self, + face: np.ndarray + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Extract LBPH and CNN features from face. + + Returns: + (lbph_features, cnn_features) + """ + lbph = self.lbph_extractor.extract(face) + cnn = self.mobile_extractor.extract(face) + return lbph, cnn + + def _process_multi_frame( + self, + frames: List[np.ndarray], + quality_list: List[Dict], + n_keep: int = 5 + ) -> Tuple[np.ndarray, np.ndarray, float]: + """ + Process multiple frames and select best by quality. + + Untuk webcam: 15 frame → 5 terbaik. + Untuk foto: gunakan semua foto (photo_mode_use_all). + + Returns: + (mean_lbph, mean_cnn, avg_quality) + """ + if len(frames) == 0: + return None, None, 0.0 + + lbph_features = [] + cnn_features = [] + + for face in frames: + lbph, cnn = self._extract_features(face) + lbph_features.append(lbph) + cnn_features.append(cnn) + + # Select top-K by quality (atau semua jika photo mode) + use_all = MULTIFRAME_CONFIG.get('photo_mode_use_all', False) + if use_all or n_keep >= len(frames): + top_indices = list(range(len(frames))) + elif quality_list and len(quality_list) == len(frames): + scores = [q.get('quality_score', 0.5) for q in quality_list] + top_indices = np.argsort(scores)[-n_keep:] + else: + top_indices = list(range(len(frames))) + + # Average + lbph_avg = np.mean([lbph_features[i] for i in top_indices], axis=0) + cnn_avg = np.mean([cnn_features[i] for i in top_indices], axis=0) + + # Normalize — PENTING: keduanya pakai L1-norm agar konsisten + # dengan histogram intersection di gallery matching. + # L1 untuk LBPH (histogram sum = 1), L2 untuk CNN (cosine similarity) + lbph_sum = lbph_avg.sum() + lbph_avg = lbph_avg / (lbph_sum + 1e-7) # L1-norm for histogram + cnn_avg = cnn_avg / (np.linalg.norm(cnn_avg) + 1e-7) # L2-norm for cosine + + # Average quality + avg_quality = np.mean([quality_list[i]['quality_score'] for i in top_indices]) if quality_list else 0.5 + + return lbph_avg, cnn_avg, avg_quality + + def enroll( + self, + nim: str, + frames: List[np.ndarray], + metadata: Dict = None + ) -> bool: + """ + Enroll a new identity. + + Strategi Embedding untuk Multi-Foto (6 foto): + ───────────────────────────────────────────── + CNN: + - Quality-Weighted Centroid: rata-rata tertimbang kualitas + (foto berkualitas tinggi berkontribusi lebih besar) + - Multi-Template: simpan semua embedding individual + (saat recognition, gunakan max-similarity rule) + + LBPH: + - Multi-template dari semua foto (L1-norm konsisten) + - Augmentasi dari 2 foto terbaik (diversitas) + - Histogram Intersection untuk matching + + Args: + nim: Unique identifier + frames: List of face crops (idealnya 6 foto) + metadata: Optional metadata + + Returns: + True if successful + """ + log.info(f"\n{'='*60}") + log.info(f"ENROLLING: {nim}") + log.info(f"{'='*60}") + + processed_frames = [] + quality_list = [] + + for i, frame in enumerate(frames): + result = self.preprocessor.detect_and_preprocess(frame, for_enrollment=True) + + if result['face'] is None: + log.warning(f" Frame {i+1}: No face detected") + continue + + if result.get('is_masked', False): + log.warning(f" Frame {i+1}: Ditolak karena terdeteksi masker ({result.get('mask_reason')})") + continue + + face = result['aligned_face'] if result['aligned_face'] is not None else result['face'] + processed_frames.append(face) + + if result['quality']: + quality_list.append(result['quality']) + log.info(f" Frame {i+1}: OK (quality: {result['quality']['quality_score']:.2f})") + else: + quality_list.append({'quality_score': 0.5}) + + if len(processed_frames) < ENROLLMENT_CONFIG.get('min_frames', 1): + log.error(f"[FAIL] Only {len(processed_frames)} valid frames") + return False + + n_photos = len(processed_frames) + quality_scores = [q.get('quality_score', 0.5) for q in quality_list] + + # ================================================================= + # CNN: Outlier Filtering + Robust Centroid + Multi-Template + # ================================================================= + # Extract CNN embedding dari setiap foto + all_cnn_embeddings = [] + for face in processed_frames: + _, cnn = self._extract_features(face) + cnn = cnn / (np.linalg.norm(cnn) + 1e-7) # L2-norm + all_cnn_embeddings.append(cnn) + + # ───────────────────────────────────────────────────────────────── + # OUTLIER FILTERING: Hapus foto dengan pose/kondisi terlalu berbeda + # + # Jika ada foto yang CNN similarity-nya < 0.45 terhadap SEMUA foto + # lain, foto itu kemungkinan: pose terlalu berbeda (strong profile), + # alignment gagal, atau ada artefak. Centroid yang mengandung + # outlier seperti ini akan "tertarik" dan tidak representatif. + # + # Strategi: hitung average mutual similarity tiap foto, + # buang yang di bawah threshold (kecuali hanya tersisa 1 foto). + # ───────────────────────────────────────────────────────────────── + n_emb = len(all_cnn_embeddings) + mutual_sims = np.zeros(n_emb) + if n_emb >= 2: + for i in range(n_emb): + sims = [] + for j in range(n_emb): + if i != j: + sims.append(float(np.dot(all_cnn_embeddings[i], + all_cnn_embeddings[j]))) + mutual_sims[i] = np.mean(sims) + + # Filter: buang embedding dengan mutual sim < 0.45 + outlier_threshold = 0.45 + keep_mask = mutual_sims >= outlier_threshold + + # Minimal tetap simpan 1 foto (yang terbaik) + if keep_mask.sum() == 0: + keep_mask[np.argmax(mutual_sims)] = True + + n_removed = n_emb - int(keep_mask.sum()) + if n_removed > 0: + log.info(f" CNN: Outlier filtering — removed {n_removed} " + f"foto(s) with mutual sim < {outlier_threshold}") + for i in range(n_emb): + if not keep_mask[i]: + log.info(f" Removed foto {i+1}: mutual_sim={mutual_sims[i]:.3f}") + + # Apply filter + filtered_embeddings = [e for e, k in zip(all_cnn_embeddings, keep_mask) if k] + filtered_frames = [f for f, k in zip(processed_frames, keep_mask) if k] + filtered_quality = [q for q, k in zip(quality_scores, keep_mask) if k] + filtered_mutual = mutual_sims[keep_mask] + else: + filtered_embeddings = all_cnn_embeddings + filtered_frames = processed_frames + filtered_quality = quality_scores + filtered_mutual = np.array([1.0]) + + # ───────────────────────────────────────────────────────────────── + # ROBUST CENTROID: Mutual-Similarity Weighted (bukan Quality-Weighted) + # + # Masalah quality-weighted centroid: quality score mengukur blur, + # brightness, pose — tapi BUKAN apakah foto konsisten dengan foto + # lain. Foto bisa quality=0.85 tapi pose sangat berbeda → centroid + # tertarik ke arah yang salah. + # + # Solusi: gunakan mutual similarity sebagai weight. + # Foto yang konsisten dengan mayoritas → bobot besar. + # Foto yang "sendirian" → bobot kecil. + # ───────────────────────────────────────────────────────────────── + ms_weights = filtered_mutual.astype(np.float32) + ms_weights = ms_weights / (ms_weights.sum() + 1e-7) + + final_cnn = np.zeros_like(filtered_embeddings[0]) + for emb, w in zip(filtered_embeddings, ms_weights): + final_cnn += w * emb + final_cnn = final_cnn / (np.linalg.norm(final_cnn) + 1e-7) # L2-norm + + # ───────────────────────────────────────────────────────────────── + # BEST-TEMPLATE AS FAISS PRIMARY ENTRY POINT + # + # Centroid (rata-rata) bisa berada di "no-man's-land" jika ada + # variasi pose. Untuk FAISS search (entry point), gunakan template + # yang paling representatif (avg mutual sim tertinggi) sebagai + # primary vector. Centroid tetap disimpan sebagai salah satu + # template untuk coverage tambahan. + # ───────────────────────────────────────────────────────────────── + best_template_idx = int(np.argmax(filtered_mutual)) + faiss_primary = filtered_embeddings[best_template_idx].copy() + faiss_primary = faiss_primary / (np.linalg.norm(faiss_primary) + 1e-7) + + log.info(f" CNN: {len(filtered_embeddings)} foto setelah filtering " + f"(dari {n_photos} asli)") + log.info(f" CNN: Mutual-sim weights = " + f"{[f'{w:.2f}' for w in ms_weights]}") + log.info(f" CNN: Best template = foto {best_template_idx+1} " + f"(mutual_sim={filtered_mutual[best_template_idx]:.3f})") + + # all_cnn_embeddings sekarang berisi hanya yang filtered + # Tambahkan centroid sebagai template tambahan + all_cnn_embeddings = list(filtered_embeddings) + all_cnn_embeddings.append(final_cnn) # centroid juga jadi template + + # Update processed_frames dan quality_scores untuk LBPH + processed_frames = filtered_frames + quality_scores = filtered_quality + n_photos = len(processed_frames) + + # ================================================================= + # LBPH: Multi-Template dari Semua Foto + Augmentasi Lighting Variants + # ================================================================= + lbph_templates = [] + + # 1. Extract LBPH dari SEMUA foto (bukan hanya average) + for face in processed_frames: + lbph_single = self.lbph_extractor.extract(face) + lbph_single = lbph_single / (lbph_single.sum() + 1e-7) # L1-norm konsisten + lbph_templates.append(lbph_single) + + # 2. Augmentasi dari 2 foto terbaik — 2 arah: + # a) Random augmentation (variasi noise/blur/rotasi) + # b) Lighting variants deterministik (7 kondisi cahaya nyata) + sorted_indices = np.argsort(quality_scores)[::-1] + top_2_indices = sorted_indices[:min(2, len(sorted_indices))] + + for idx in top_2_indices: + # --- 2a. Random augmentation (seperti sebelumnya) --- + aug_versions = self.augmentation.generate_versions( + processed_frames[idx], + n_versions=2, + include_original=False + ) + for aug_face in aug_versions: + if aug_face is not None: + lbph = self.lbph_extractor.extract(aug_face) + lbph = lbph / (lbph.sum() + 1e-7) # L1-norm konsisten + lbph_templates.append(lbph) + + # --- 2b. Lighting variants deterministik dari foto terbaik --- + # Menghasilkan 7 kondisi cahaya: terang, gelap, sangat gelap, + # overexposed, shadow kiri, shadow kanan, CLAHE agresif + lighting_variants = self.augmentation.generate_lighting_variants( + processed_frames[idx], + include_original=False + ) + for light_face in lighting_variants: + if light_face is not None: + lbph = self.lbph_extractor.extract(light_face) + lbph = lbph / (lbph.sum() + 1e-7) # L1-norm konsisten + lbph_templates.append(lbph) + + # --- 2c. Android camera variants deterministik --- + # Menghasilkan 10 kondisi spesifik kamera Android: + # neon, tungsten, outdoor teduh, outdoor terang, malam, + # backlight, jendela kiri/kanan, terkompresi, kamera depan + android_variants = self.augmentation.generate_android_variants( + processed_frames[idx], + include_original=False + ) + for android_face in android_variants: + if android_face is not None: + lbph = self.lbph_extractor.extract(android_face) + lbph = lbph / (lbph.sum() + 1e-7) # L1-norm konsisten + lbph_templates.append(lbph) + + # ================================================================= + # CNN: Augmented Templates dari Foto Terbaik + # ================================================================= + # Selain centroid dan foto individual, tambahkan CNN embedding + # dari lighting + android variants foto terbaik. + # Ini memastikan gallery CNN punya coverage di berbagai kondisi + # pencahayaan, sehingga similarity tetap tinggi saat foto real + # dari Android berbeda kondisi dari foto enrollment. + best_photo_idx = sorted_indices[0] if len(sorted_indices) > 0 else 0 + + # Lighting variants → CNN templates + light_cnn_variants = self.augmentation.generate_lighting_variants( + processed_frames[best_photo_idx], + include_original=False + ) + for light_face in light_cnn_variants: + if light_face is not None: + _, cnn = self._extract_features(light_face) + cnn = cnn / (np.linalg.norm(cnn) + 1e-7) + all_cnn_embeddings.append(cnn) + + # Android variants → CNN templates + android_cnn_variants = self.augmentation.generate_android_variants( + processed_frames[best_photo_idx], + include_original=False + ) + for android_face in android_cnn_variants: + if android_face is not None: + _, cnn = self._extract_features(android_face) + cnn = cnn / (np.linalg.norm(cnn) + 1e-7) + all_cnn_embeddings.append(cnn) + + log.info(f" CNN: +{len(light_cnn_variants) + len(android_cnn_variants)} " + f"augmented templates (7 lighting + 10 android)") + + # LBPH: Kirim SEMUA templates individual ke gallery + # Gallery akan menyimpan semuanya dan melakukan max/avg matching + # (tidak di-average di sini agar gallery punya cakupan variasi lebih luas) + # Tetap buat final_lbph sebagai fallback saja + final_lbph = np.mean(lbph_templates, axis=0) + final_lbph = final_lbph / (final_lbph.sum() + 1e-7) # L1-norm + + log.info(f" LBPH: {len(lbph_templates)} templates individual ({n_photos} foto + augmentasi)" + f" — akan disimpan semua di gallery") + + # ================================================================= + # Add to Gallery + # ================================================================= + meta = metadata or {} + avg_quality = float(np.mean(quality_scores)) + meta['quality_score'] = avg_quality + meta['num_registrations'] = n_photos + + # Multi-template CNN: simpan semua embedding individual + use_multi_template = ENROLLMENT_CONFIG.get('multi_template_cnn', True) + + success = self.gallery.add_identity( + nim, + faiss_primary, # Best-template sebagai FAISS primary (bukan centroid) + final_lbph, + meta, + cnn_templates=all_cnn_embeddings if use_multi_template else None, + lbph_templates=lbph_templates # Fix 4: kirim semua LBPH individual + ) + + if success: + log.info(f"\n{'='*60}") + log.info(f"ENROLLMENT SUCCESS") + log.info(f"{'='*60}") + log.info(f" NIM: {nim}") + log.info(f" Foto: {n_photos}") + log.info(f" Avg Quality: {avg_quality:.2f}") + log.info(f" CNN: Quality-Weighted Centroid + {len(all_cnn_embeddings)} individual templates") + log.info(f" LBPH: {len(lbph_templates)} individual templates (multi-foto + augmentasi)") + + return success + + + def enroll_from_images( + self, + nim: str, + image_paths: List[str], + metadata: Dict = None + ) -> bool: + """Enroll from image files.""" + frames = [] + for path in image_paths: + if isinstance(path, str): + img = cv2.imread(str(path)) + if img is not None: + frames.append(img) + + return self.enroll(nim, frames, metadata) + + def recognize( + self, + frame: np.ndarray, + update_template: bool = True + ) -> RecognitionResult: + """ + Recognize face in a frame. + + Score-Level Fusion: + 1. Extract LBPH and CNN features + 2. Match against gallery + 3. Fuse scores with quality-aware weights + """ + # Detect and preprocess + result = self.preprocessor.detect_and_preprocess(frame, for_enrollment=False) + + if result['face'] is None: + return RecognitionResult(nim=None, similarity=0.0, message="Wajah tidak terdeteksi") + + # Cek apakah wajah menggunakan masker + if result.get('is_masked', False): + mask_reason = result.get('mask_reason', 'Terdeteksi masker') + log.warning(f"[EnhancedSystem] Rekognisi ditolak: {mask_reason}") + return RecognitionResult( + nim=None, + similarity=0.0, + cnn_similarity=0.0, + lbph_similarity=0.0, + is_recognized=False, + message="Wajah terdeteksi menggunakan masker. Harap lepas masker Anda terlebih dahulu!" + ) + + # Use aligned face + face = result['aligned_face'] if result['aligned_face'] is not None else result['face'] + quality = result['quality'] + + # Extract features + lbph, cnn = self._extract_features(face) + + # Normalize + lbph = lbph / (lbph.sum() + 1e-7) # L1-norm (histogram intersection) + cnn = cnn / (np.linalg.norm(cnn) + 1e-7) # L2-norm (cosine similarity) + + # Get threshold + threshold = self.calibrator.get_threshold() + + # Recognize with Score-Level Fusion + result = self.gallery.recognize( + cnn, + lbph, + quality_score=quality['quality_score'] if quality else 0.5, + threshold=threshold, + update_template=update_template + ) + + return result + + def recognize_from_image( + self, + image_path: str, + update_template: bool = True + ) -> RecognitionResult: + """Recognize from image file.""" + img = cv2.imread(str(image_path)) + if img is None: + return RecognitionResult(nim=None, similarity=0.0) + + return self.recognize(img, update_template) + + def verify( + self, + frame: np.ndarray, + nim: str + ) -> Tuple[bool, float]: + """Verify if face matches specific identity.""" + result = self.preprocessor.detect_and_preprocess(frame, for_enrollment=False) + + if result['face'] is None: + return False, 0.0 + + face = result['aligned_face'] if result['aligned_face'] is not None else result['face'] + quality = result['quality'] + + lbph, cnn = self._extract_features(face) + lbph = lbph / (lbph.sum() + 1e-7) # L1-norm (histogram intersection) + cnn = cnn / (np.linalg.norm(cnn) + 1e-7) # L2-norm (cosine similarity) + + threshold = self.calibrator.get_threshold() + + # Normalize NIM ke uppercase untuk case-insensitive matching + nim_upper = nim.strip().upper() + + return self.gallery.verify_identity( + nim_upper, cnn, lbph, + quality_score=quality['quality_score'] if quality else 0.5, + threshold=threshold + ) + + def get_gallery_size(self) -> int: + """Get number of enrolled identities.""" + return self.gallery.get_identity_count() + + def list_identities(self) -> List[str]: + """List enrolled identities.""" + return self.gallery.list_identities() + + def remove_identity(self, nim: str) -> bool: + """Remove identity from gallery.""" + return self.gallery.remove_identity(nim) + + def get_gallery_info(self) -> Dict: + """Get gallery info including quality stats.""" + return self.gallery.get_gallery_stats() + + +# Backward compatibility +class SimpleFusionExtractor: + """Simple fusion extractor (for testing).""" + + def __init__(self): + self.lbph = LBPHExtractor() + self.cnn = MobileFaceNetExtractor() + + def extract_embedding(self, face: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Extract LBPH and CNN features.""" + lbph = self.lbph.extract(face) + cnn = self.cnn.extract(face) + return lbph, cnn + + +# Factory function +def create_system() -> EnhancedFaceRecognitionSystem: + """Create enhanced face recognition system.""" + return EnhancedFaceRecognitionSystem() + + +# Test function +if __name__ == "__main__": + log.info("Testing Enhanced Face Recognition System (Revisi)...") + + system = EnhancedFaceRecognitionSystem() + log.info(f"Gallery size: {system.get_gallery_size()}") + log.info("\nSystem initialized with Score-Level Fusion!") diff --git a/facerecognition_presensi/ModelBaru/enroll_all.py b/facerecognition_presensi/ModelBaru/enroll_all.py new file mode 100644 index 0000000..1e931d3 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/enroll_all.py @@ -0,0 +1,78 @@ +import sys +import os +import argparse +from pathlib import Path + +MODELBARU_DIR = Path(__file__).resolve().parent +FACE_DIR = MODELBARU_DIR / "face_mahasiswa" +MODELS_DIR = MODELBARU_DIR / "models" + +CURRENT_GALLERY_FILES = [ + MODELS_DIR / "face_metadata.pkl", + MODELS_DIR / "face_index.faiss", +] +LEGACY_GALLERY_FILES = [ + MODELS_DIR / "face_gallery.pkl", + MODELS_DIR / "face_gallery_faiss", +] + +sys.path.insert(0, str(MODELBARU_DIR)) +from enroll_model import enroll_identity + + +def find_identities(): + identities = [] + for nim_dir in sorted(FACE_DIR.iterdir()): + if not nim_dir.is_dir(): + continue + images = [ + str(path) + for path in sorted(nim_dir.iterdir()) + if path.suffix.lower() in {".jpg", ".jpeg", ".png"} + ] + if images: + identities.append((nim_dir.name, images)) + return identities + + +def reset_gallery(): + for path in CURRENT_GALLERY_FILES + LEGACY_GALLERY_FILES: + if path.exists() and path.is_file(): + os.remove(path) + print(f"[*] Deleted {path.name}") + + +def main(): + parser = argparse.ArgumentParser(description="Enroll semua folder di face_mahasiswa") + parser.add_argument( + "--reset", + action="store_true", + help="hapus gallery lama sebelum enroll ulang", + ) + args = parser.parse_args() + + if args.reset: + reset_gallery() + + identities = find_identities() + + print("=" * 60) + print("ENROLLING ALL IDENTITIES (LBPH + MobileFaceNet)") + print("=" * 60) + + success = 0 + for nim, image_paths in identities: + print(f"\n[*] Enrolling {nim} ({len(image_paths)} foto)...") + if enroll_identity(nim, image_paths): + print(f"[OK] {nim} enrolled!") + success += 1 + else: + print(f"[FAIL] {nim}") + + print("\n" + "=" * 60) + print(f"DONE! {success}/{len(identities)} enrolled successfully.") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/facerecognition_presensi/ModelBaru/enroll_model.py b/facerecognition_presensi/ModelBaru/enroll_model.py new file mode 100644 index 0000000..1b289ac --- /dev/null +++ b/facerecognition_presensi/ModelBaru/enroll_model.py @@ -0,0 +1,185 @@ +""" +Enhanced Enrollment Script (Revisi) +================================ +Enrollment dengan Quality-Aware Score-Level Fusion. + +Author: Skripsi 2026 +""" + +import os +import sys +import cv2 +import logging +from pathlib import Path +from typing import List +import argparse + +_CURRENT_FILE = Path(__file__).resolve() +_MODELBARU_DIR = _CURRENT_FILE.parent +sys.path.insert(0, str(_MODELBARU_DIR)) + +from config import FACE_MAHASISWA_DIR, ENROLLMENT_CONFIG +from enhanced_system import EnhancedFaceRecognitionSystem + +os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2' +logging.basicConfig(level=logging.INFO, format='%(message)s') +log = logging.getLogger(__name__) + + +def enroll_single( + system: EnhancedFaceRecognitionSystem, + nim: str, + image_paths: List[str] +) -> bool: + """Enroll single identity.""" + log.info(f"\n{'='*60}") + log.info(f"ENROLLING: {nim}") + log.info(f"{'='*60}") + + frames = [] + for path in image_paths: + if isinstance(path, str): + img = cv2.imread(str(path)) + else: + img = path + if img is not None: + frames.append(img) + + if len(frames) == 0: + log.error(f"[FAIL] No valid images for {nim}") + return False + + log.info(f" Loaded {len(frames)} images") + return system.enroll(nim, frames) + + +def enroll_directory(system: EnhancedFaceRecognitionSystem) -> dict: + """Batch enrollment from face_mahasiswa directory.""" + log.info(f"\n{'='*60}") + log.info(f"BATCH ENROLLMENT") + log.info(f"{'='*60}") + + results = {} + total = 0 + success = 0 + + for nim_dir in sorted(FACE_MAHASISWA_DIR.iterdir()): + if not nim_dir.is_dir(): + continue + + nim = nim_dir.name + images = [str(f) for f in sorted(nim_dir.iterdir()) + if f.suffix.lower() in ['.jpg', '.jpeg', '.png']] + + if images: + total += 1 + results[nim] = enroll_single(system, nim, images) + if results[nim]: + success += 1 + + log.info(f"\n{'='*60}") + log.info(f"RESULT: {success}/{total} successful") + log.info(f"{'='*60}") + + return results + + +def webcam_enrollment(system: EnhancedFaceRecognitionSystem, nim: str, n_frames: int = 15): + """Webcam enrollment with auto-capture.""" + log.info(f"\n{'='*60}") + log.info(f"WEBCAM ENROLLMENT: {nim}") + log.info(f"{'='*60}") + + cap = cv2.VideoCapture(0) + if not cap.isOpened(): + log.error("[!] Cannot open webcam") + return False + + frames = [] + log.info("[*] Press SPACE to capture, 'q' to finish") + + while len(frames) < n_frames: + ret, frame = cap.read() + if not ret: + break + + # Detect face + result = system.preprocessor.detect_and_preprocess(frame, for_enrollment=True) + + if result['face'] is not None: + x, y, bw, bh = result['bbox'] + color = (0, 255, 0) + cv2.rectangle(frame, (x, y), (x+bw, y+bh), color, 2) + + if result['quality']: + q = result['quality']['quality_score'] + cv2.putText(frame, f"Q: {q:.2f}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) + + cv2.putText(frame, f"Frames: {len(frames)}/{n_frames}", (10, 60), + cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) + + cv2.imshow('Webcam Enrollment', frame) + key = cv2.waitKey(1) & 0xFF + + if key == ord('q'): + break + elif key == ord(' ') or len(frames) < 3: + if result['face'] is not None: + frames.append(frame.copy()) + log.info(f" Captured {len(frames)} (Q: {result['quality']['quality_score']:.2f})") + + cap.release() + cv2.destroyAllWindows() + + if len(frames) < ENROLLMENT_CONFIG.get('min_frames', 3): + log.error(f"[FAIL] Only {len(frames)} frames") + return False + + return system.enroll(nim, frames) + + +def main(): + parser = argparse.ArgumentParser(description='Face Enrollment (Score-Level Fusion)') + parser.add_argument('--nim', '-n', type=str, help='NIM/ID') + parser.add_argument('--images', '-i', type=str, nargs='+', help='Image files') + parser.add_argument('--batch', '-b', action='store_true', help='Batch enroll') + parser.add_argument('--webcam', '-w', action='store_true', help='Webcam enrollment') + parser.add_argument('--frames', '-f', type=int, default=15, help='Frames for webcam') + parser.add_argument('--gallery', '-g', action='store_true', help='Show gallery') + parser.add_argument('--remove', '-r', type=str, help='Remove identity') + + args = parser.parse_args() + + system = EnhancedFaceRecognitionSystem() + + if args.gallery: + log.info(f"\nGallery: {system.get_gallery_size()} identities") + for nim in system.list_identities(): + info = system.gallery.get_identity_info(nim) + if info: + log.info(f" - {nim}: Q={info.quality_score:.2f}, " + f"enrolled={info.enrollment_date[:10] if info.enrollment_date else 'N/A'}") + + elif args.remove: + system.remove_identity(args.remove) + log.info(f"[OK] Removed {args.remove}") + + elif args.batch: + enroll_directory(system) + + elif args.webcam and args.nim: + if webcam_enrollment(system, args.nim, args.frames): + log.info(f"\n[OK] {args.nim} enrolled!") + + elif args.nim and args.images: + if enroll_single(system, args.nim, args.images): + log.info(f"\n[OK] {args.nim} enrolled!") + else: + sys.exit(1) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/facerecognition_presensi/ModelBaru/face_preprocessing.py b/facerecognition_presensi/ModelBaru/face_preprocessing.py new file mode 100644 index 0000000..b6a7cab --- /dev/null +++ b/facerecognition_presensi/ModelBaru/face_preprocessing.py @@ -0,0 +1,671 @@ +""" +Face Preprocessing Module (Revisi) +================================ +Contains: +1. FaceAlignment - Using YuNet 5 landmarks for affine transformation +2. FaceQualityAssessment - Simplified to 3 metrics (Blur, Brightness, Pose) + +Revisi: Quality assessment disederhanakan menjadi 3 metrik saja +- Blur (Variance of Laplacian) +- Brightness (Mean pixel) +- Pose (Yaw/Pitch dari landmark) + +Author: Skripsi 2026 +""" + +import cv2 +import numpy as np +from typing import Tuple, Dict, Optional +import logging + +from config import ( + ALIGNMENT_CONFIG, QUALITY_CONFIG, DETECTION_CONFIG, + YUNET_MODEL_PATH +) + +log = logging.getLogger(__name__) + + +class FaceAlignment: + """ + Face Alignment using YuNet's 5 facial landmarks. + + Uses 5-point similarity transform (cv2.estimateAffinePartial2D) for better + alignment, matching how ArcFace models are trained with InsightFace. + + YuNet outputs 5 landmarks: + 0: left_eye + 1: right_eye + 2: nose + 3: left_mouth + 4: right_mouth + """ + + def __init__(self, target_size: int = None): + self.target_size = target_size or ALIGNMENT_CONFIG['target_size'] + self.reference_points = ALIGNMENT_CONFIG['reference_points'].copy() + scale = self.target_size / ALIGNMENT_CONFIG['reference_scale'] + self.reference_points *= scale + + def align_face( + self, + face_crop: np.ndarray, + landmarks: np.ndarray + ) -> Tuple[np.ndarray, Dict]: + """ + Align face using 5-point similarity transform. + + Uses cv2.estimateAffinePartial2D for better alignment that matches + how ArcFace/InsightFace models are trained. + + Args: + face_crop: Original face crop (BGR) + landmarks: 5 facial landmarks + + Returns: + Tuple of (aligned_face, alignment_info) + """ + if landmarks is None or len(landmarks) < 5: + return face_crop, {'aligned': False} + + try: + # Use all 5 landmarks for better alignment + src_points = landmarks.astype(np.float64) + + # 5-point similarity transform using RANSAC for robustness + # estimateAffinePartial2D finds the optimal similarity transform + # that best maps src_points to reference_points + matrix, inliers = cv2.estimateAffinePartial2D( + src_points, + self.reference_points, + method=cv2.RANSAC, + ransacReprojThreshold=3.0 + ) + + if matrix is None: + return face_crop, {'aligned': False} + + aligned = cv2.warpAffine( + face_crop, + matrix, + (self.target_size, self.target_size), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REFLECT + ) + + # Count inliers for quality assessment + n_inliers = np.sum(inliers) if inliers is not None else 5 + alignment_quality = n_inliers / 5.0 + + return aligned, { + 'aligned': True, + 'matrix': matrix, + 'inliers': int(n_inliers), + 'quality': alignment_quality + } + + except Exception as e: + log.warning(f"Alignment failed: {e}") + return face_crop, {'aligned': False} + + +class FaceQualityAssessment: + """ + Simplified Face Quality Assessment (3 Metrik). + + Revisi: Hanya 3 metrik untuk kemudahan validasi: + 1. Blur - Variance of Laplacian + 2. Brightness - Mean pixel intensity + 3. Pose - Yaw/Pitch dari landmark offset + + Formula Quality Score: + Q = (blur_ok + brightness_ok + pose_ok) / 3 + Pass if Q >= 0.5 (minimal 2 dari 3 metrik bagus) + """ + + def __init__(self): + self.config = QUALITY_CONFIG + + def assess_blur(self, face: np.ndarray, brightness_hint: float = None) -> Tuple[bool, float]: + """ + Assess blur using Variance of Laplacian. + + Revisi Low-Light: Threshold adaptif berdasarkan kecerahan. + Gambar gelap secara natural memiliki Laplacian variance lebih rendah + karena kontras lokal memang lebih rendah — bukan berarti blur! + Threshold diturunkan saat gelap agar wajah valid tidak ditolak. + + Args: + face: Face crop + brightness_hint: Mean brightness (opsional, untuk threshold adaptif) + + Returns: + Tuple of (is_good, score) + """ + if len(face.shape) == 3: + gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) + else: + gray = face + + gray = cv2.resize(gray, (128, 128)) + variance = cv2.Laplacian(gray, cv2.CV_64F).var() + + # Revisi: threshold adaptif berdasarkan brightness + # Saat gelap (mean < 60): threshold diturunkan ke 12 (dari 30) + # Saat normal (mean >= 60): threshold tetap 30 + if brightness_hint is None: + brightness_hint = float(np.mean(gray)) + + if brightness_hint < 40: + threshold = 12.0 # Sangat gelap: sangat toleran + elif brightness_hint < 60: + threshold = 18.0 # Gelap: toleran + elif brightness_hint < 80: + threshold = 22.0 # Agak gelap: sedikit toleran + else: + threshold = self.config['blur']['threshold'] # Normal: 30.0 + + if variance < threshold: + return False, variance / threshold # 0-1 score + else: + return True, min(1.0, variance / (threshold * 2)) + + def assess_brightness(self, face: np.ndarray) -> Tuple[bool, float]: + """ + Assess brightness level. + + Revisi Low-Light: Score lebih smooth untuk kondisi indoor. + Menggunakan curve yang lebih gradual agar skor tidak langsung 0 + saat kondisi gelap. + + Args: + face: Face crop + + Returns: + Tuple of (is_good, score) + """ + if len(face.shape) == 3: + gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) + else: + gray = face + + mean_brightness = np.mean(gray) + min_b = self.config['brightness']['min'] # 20 (revisi) + max_b = self.config['brightness']['max'] # 230 (revisi) + + if mean_brightness < min_b or mean_brightness > max_b: + # Score berdasarkan jarak ke range optimal + if mean_brightness < min_b: + # Low-light: score lebih smooth (tidak langsung 0) + # Saat brightness=10 dengan min=20 → score = 0.5 (bukan 0.25) + score = 0.3 + 0.7 * (mean_brightness / min_b) + else: + score = max_b / mean_brightness + return False, float(max(0.0, min(1.0, score))) + else: + # Dalam range: score 1.0 di tengah, sedikit turun di pinggir + optimal = (min_b + max_b) / 2 + deviation = abs(mean_brightness - optimal) / ((max_b - min_b) / 2) + return True, float(max(0.7, 1.0 - 0.3 * deviation)) + + def assess_pose(self, landmarks: np.ndarray = None) -> Tuple[bool, float]: + """ + Assess head pose from landmarks. + + Args: + landmarks: 5 facial landmarks + + Returns: + Tuple of (is_good, score) + """ + if landmarks is None or len(landmarks) < 5: + return True, 0.7 # Unknown = assume good + + left_eye = landmarks[0] + right_eye = landmarks[1] + nose = landmarks[2] + + eye_dist = np.linalg.norm(right_eye - left_eye) + if eye_dist < 1: + return True, 0.7 + + eye_mid = (left_eye + right_eye) / 2 + + # Yaw: nose horizontal offset from the eye line. This is a + # scale-normalized proxy, so it remains meaningful for different + # face-crop sizes. + yaw = abs(nose[0] - eye_mid[0]) / eye_dist * 45 + + # Pitch must not be computed from the absolute eye-to-nose distance. + # On a normal frontal face the nose is naturally below the eyes, so + # the old formula marked many frontal faces as strongly pitched. + # Compare the observed ratio with the frontal reference geometry + # instead. This keeps the quality score about pose, not anatomy. + mouth_mid = (landmarks[3] + landmarks[4]) / 2 + ref = ALIGNMENT_CONFIG['reference_points'] + ref_eye_dist = np.linalg.norm(ref[1] - ref[0]) + ref_eye_nose = (ref[2][1] - (ref[0][1] + ref[1][1]) / 2) / ref_eye_dist + ref_nose_mouth = (ref[3][1] + ref[4][1]) / 2 - ref[2][1] + ref_nose_mouth /= ref_eye_dist + + eye_nose = (nose[1] - eye_mid[1]) / eye_dist + nose_mouth = (mouth_mid[1] - nose[1]) / eye_dist + pitch = (abs(eye_nose - ref_eye_nose) + + abs(nose_mouth - ref_nose_mouth)) * 22.5 + + yaw_th = self.config['pose']['yaw_threshold'] + pitch_th = self.config['pose']['pitch_threshold'] + + # Score based on deviation + yaw_score = max(0, 1 - yaw / yaw_th) + pitch_score = max(0, 1 - pitch / pitch_th) + + is_good = yaw < yaw_th and pitch < pitch_th + + return is_good, (yaw_score + pitch_score) / 2 + + def assess( + self, + face: np.ndarray, + landmarks: np.ndarray = None + ) -> Dict: + """ + Comprehensive face quality assessment (3 metrik). + + Revisi Low-Light: brightness dihitung terlebih dahulu, + lalu nilainya diteruskan ke assess_blur sebagai hint + untuk menentukan threshold adaptif. + + Args: + face: Face crop (BGR) + landmarks: 5 facial landmarks (optional) + + Returns: + Dictionary with quality scores + """ + # Hitung brightness terlebih dahulu (sebagai hint untuk blur) + brightness_ok, brightness_score = self.assess_brightness(face) + + # Hitung mean brightness untuk hint adaptif blur threshold + if len(face.shape) == 3: + gray_hint = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) + else: + gray_hint = face + mean_brightness_hint = float(np.mean(gray_hint)) + + # Blur assessment dengan hint brightness (threshold adaptif) + blur_ok, blur_score = self.assess_blur(face, brightness_hint=mean_brightness_hint) + + pose_ok, pose_score = self.assess_pose(landmarks) + + # Calculate quality score + # Q = rata-rata dari 3 metrik + quality_score = (blur_score + brightness_score + pose_score) / 3 + + # Determine status + metriks_passed = sum([blur_ok, brightness_ok, pose_ok]) + + if metriks_passed >= 2: + status = 'good' if quality_score >= 0.6 else 'acceptable' + else: + status = 'poor' + + return { + 'blur': { + 'ok': blur_ok, + 'score': blur_score + }, + 'brightness': { + 'ok': brightness_ok, + 'score': brightness_score + }, + 'pose': { + 'ok': pose_ok, + 'score': pose_score + }, + 'quality_score': quality_score, + 'status': status, + 'metriks_passed': metriks_passed, + 'mean_brightness': mean_brightness_hint, # Tambahan: untuk debug low-light + } + + def meets_threshold(self, quality_results: Dict) -> bool: + """Check if face meets quality threshold.""" + return quality_results['quality_score'] >= self.config.get('pass_threshold', 0.5) + + +class FacePreprocessor: + """ + Unified Face Preprocessing Pipeline. + """ + + def __init__(self): + self.detector = None + self.haar = None + self.alignment = FaceAlignment() + self.quality = FaceQualityAssessment() + self._load_detector() + + def _load_detector(self): + """Load YuNet face detector.""" + if YUNET_MODEL_PATH.exists(): + try: + self.detector = cv2.FaceDetectorYN.create( + str(YUNET_MODEL_PATH), + "", + (320, 320), + score_threshold=DETECTION_CONFIG['face_confidence_threshold'], + nms_threshold=DETECTION_CONFIG['face_nms_threshold'], + top_k=DETECTION_CONFIG['face_max_faces'] + ) + log.info("[Preprocessor] YuNet loaded") + except Exception as e: + log.warning(f"[Preprocessor] YuNet failed: {e}") + self.detector = None + + # Haar is a conservative fallback for low-resolution or low-light + # images where YuNet may return no candidate at all. It is not used + # when YuNet succeeds, so normal inference remains YuNet-based. + haar_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' + haar = cv2.CascadeClassifier(haar_path) + if not haar.empty(): + self.haar = haar + log.info('[Preprocessor] Haar fallback loaded') + + def detect_mask(self, aligned_face: np.ndarray) -> Tuple[bool, str, Dict]: + """ + Deteksi apakah wajah pada citra aligned (112x112) tertutup masker + atau memiliki oklusi pada area hidung & mulut. + + Metode: Analisis krominans kulit (YCrCb) dan fotometri warna pada + daerah periokular atas (dahi/mata) vs daerah wajah bawah (hidung/mulut). + + Returns: + Tuple[is_masked (bool), reason (str), metrics (dict)] + """ + if aligned_face is None or aligned_face.shape[:2] != (112, 112): + return False, "Wajah tidak valid", {} + + upper_roi = aligned_face[25:55, 25:87] + lower_roi = aligned_face[65:105, 25:87] + mouth_roi = aligned_face[80:105, 30:82] + + ycrcb_upper = cv2.cvtColor(upper_roi, cv2.COLOR_BGR2YCrCb) + ycrcb_lower = cv2.cvtColor(lower_roi, cv2.COLOR_BGR2YCrCb) + ycrcb_mouth = cv2.cvtColor(mouth_roi, cv2.COLOR_BGR2YCrCb) + + def compute_skin_ratio(ycrcb_img): + cr = ycrcb_img[:, :, 1] + cb = ycrcb_img[:, :, 2] + # Rentang standar krominans kulit manusia: Cr in [133, 173], Cb in [77, 127] + mask = (cr >= 133) & (cr <= 173) & (cb >= 77) & (cb <= 127) + return float(np.mean(mask)) + + skin_upper = compute_skin_ratio(ycrcb_upper) + skin_lower = compute_skin_ratio(ycrcb_lower) + skin_mouth = compute_skin_ratio(ycrcb_mouth) + + mean_upper = float(np.mean(upper_roi)) + mean_lower = float(np.mean(lower_roi)) + bright_ratio = mean_lower / (mean_upper + 1e-5) + + hsv_lower = cv2.cvtColor(lower_roi, cv2.COLOR_BGR2HSV) + hsv_upper = cv2.cvtColor(upper_roi, cv2.COLOR_BGR2HSV) + sat_lower = float(np.mean(hsv_lower[:, :, 1])) + sat_upper = float(np.mean(hsv_upper[:, :, 1])) + + b_mean = float(np.mean(lower_roi[:, :, 0])) + g_mean = float(np.mean(lower_roi[:, :, 1])) + r_mean = float(np.mean(lower_roi[:, :, 2])) + + metrics = { + 'skin_upper': skin_upper, + 'skin_lower': skin_lower, + 'skin_mouth': skin_mouth, + 'bright_ratio': bright_ratio, + 'mean_lower': mean_lower, + 'sat_lower': sat_lower + } + + # 1. Masker Gelap / Hitam (kain scuba / medis hitam) + if skin_lower < 0.40 and (bright_ratio < 0.60 or mean_lower < 60.0): + return True, "Terdeteksi masker gelap/hitam menutupi mulut dan hidung", metrics + + # 2. Masker Medis Biru / Hijau (surgical mask) + if (b_mean > r_mean + 10 or g_mean > r_mean + 15) and skin_lower < 0.45: + return True, "Terdeteksi masker medis biru/hijau menutupi mulut dan hidung", metrics + + # 3. Masker Putih / Abu-abu terang (achromatic mask) + if sat_lower < 25.0 and sat_upper > 35.0 and skin_lower < 0.45: + return True, "Terdeteksi masker putih/terang menutupi mulut dan hidung", metrics + + # 4. Oklusi Kulit Umum (Kain / masker motif lain pada area mulut) + if skin_upper >= 0.50 and skin_mouth < 0.30: + return True, "Area mulut dan hidung terhalang masker atau penutup wajah", metrics + + return False, "Wajah terbuka normal", metrics + + def detect_and_preprocess( + self, + image: np.ndarray, + for_enrollment: bool = False + ) -> Dict: + """ + Full detection and preprocessing pipeline. + + Returns: + Dictionary with face, aligned_face, landmarks, quality, ready status, is_masked + """ + h, w = image.shape[:2] + + result = { + 'face': None, + 'aligned_face': None, + 'landmarks': None, + 'quality': None, + 'bbox': None, + 'ready': False, + 'detection_confidence': 0.0, + 'is_masked': False, + 'mask_reason': '', + 'mask_metrics': {} + } + + # Face Detection with Multi-Scale fallback + # ───────────────────────────────────────────────────────────────── + # Close-up selfie photos (common from Android front camera) often + # have faces filling >80% of the frame. YuNet works best when + # faces occupy 20-60% of the input. When detection at native + # resolution fails, we try progressively downscaled versions so + # the face becomes proportionally smaller and detectable. + # Coordinates are mapped back to the original image space. + # ───────────────────────────────────────────────────────────────── + if self.detector is not None: + face_info = self._detect_face_multiscale(image) + + if face_info is not None: + x, y, bw, bh = map(int, face_info[:4]) + conf = float(face_info[14]) + + if bw >= DETECTION_CONFIG['min_face_size'] and conf >= DETECTION_CONFIG['face_confidence_threshold']: + # Crop with padding + pad = int(DETECTION_CONFIG['padding_ratio'] * min(bw, bh)) + x1 = max(0, x - pad) + y1 = max(0, y - pad) + x2 = min(w, x + bw + pad) + y2 = min(h, y + bh + pad) + + result['face'] = image[y1:y2, x1:x2] + result['bbox'] = (x1, y1, x2 - x1, y2 - y1) + result['detection_confidence'] = conf + + # Extract landmarks from image coordinates and translate them + # into the padded face crop coordinate space before alignment. + landmarks_global = face_info[4:14].reshape(5, 2).astype(np.float64) + crop_offset = np.array([x1, y1], dtype=np.float64) + result['landmarks'] = landmarks_global - crop_offset + + # Align face + aligned, align_info = self.alignment.align_face( + result['face'], + result['landmarks'] + ) + result['aligned_face'] = aligned + + # Assess quality on the detected crop and its original + # landmarks. The aligned image changes the coordinate + # system; using the old landmarks on that image makes + # pose scores inconsistent. + result['quality'] = self.quality.assess( + result['face'], + result['landmarks'] + ) + + # Check for mask / occlusion on aligned face + is_masked, mask_reason, mask_metrics = self.detect_mask(result['aligned_face']) + result['is_masked'] = is_masked + result['mask_reason'] = mask_reason + result['mask_metrics'] = mask_metrics + + if is_masked: + result['ready'] = False + if result['quality']: + result['quality']['status'] = 'masked' + result['quality']['mask_ok'] = False + result['quality']['quality_score'] = min(result['quality']['quality_score'], 0.20) + result['quality']['feedback_messages'] = [ + "Wajah terdeteksi menggunakan masker. Harap lepas masker Anda terlebih dahulu!" + ] + else: + # Check if ready + threshold = 0.4 if for_enrollment else 0.5 + result['ready'] = result['quality']['quality_score'] >= threshold + + # Fallback detection for difficult camera frames. Haar does not + # provide the five landmarks required for alignment, so the original + # crop is used and pose quality receives the documented neutral score. + if result['face'] is None and self.haar is not None: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + gray = cv2.equalizeHist(gray) + min_side = max(40, min(DETECTION_CONFIG['min_face_size'], min(h, w) // 8)) + haar_faces = self.haar.detectMultiScale( + gray, + scaleFactor=1.08, + minNeighbors=4, + minSize=(min_side, min_side), + ) + if len(haar_faces) > 0: + x, y, bw, bh = max(haar_faces, key=lambda box: box[2] * box[3]) + pad = int(DETECTION_CONFIG['padding_ratio'] * min(bw, bh)) + x1 = max(0, int(x - pad)) + y1 = max(0, int(y - pad)) + x2 = min(w, int(x + bw + pad)) + y2 = min(h, int(y + bh + pad)) + result['face'] = image[y1:y2, x1:x2] + result['bbox'] = (x1, y1, x2 - x1, y2 - y1) + result['detection_confidence'] = 0.0 + result['quality'] = self.quality.assess(result['face'], None) + threshold = 0.4 if for_enrollment else 0.5 + result['ready'] = result['quality']['quality_score'] >= threshold + + return result + + def _detect_face_multiscale(self, image: np.ndarray): + """ + Multi-scale face detection using YuNet. + + Always tries native resolution AND downscaled versions, then + returns whichever detection has the **highest confidence**. + + This is critical for close-up selfie photos: native detection may + find a face with low confidence (0.67) and poor landmarks, while + detection at 0.5x scale finds the same face with high confidence + (0.91) and much better landmarks. Using the highest-confidence + detection ensures consistent alignment across all photos. + + Returns: + face_info array (15 values: x,y,w,h, 5 landmarks x 2, conf) + or None if no face detected at any scale. + """ + h, w = image.shape[:2] + best_face = None + best_conf = -1.0 + best_scale_name = "native" + + # Try native resolution + self.detector.setInputSize((w, h)) + _, faces = self.detector.detect(image) + if faces is not None and len(faces) > 0: + idx = np.argmax(faces[:, 14]) + conf = float(faces[idx, 14]) + if conf > best_conf: + best_face = faces[idx].copy() + best_conf = conf + best_scale_name = "1.0" + + # Also try downscaled versions — always, not just as fallback + # For close-up photos, downscaled often gives BETTER detection + scales = [0.5, 0.4, 0.3] + for scale in scales: + sh, sw = int(h * scale), int(w * scale) + if sh < 100 or sw < 100: + continue + + small = cv2.resize(image, (sw, sh), interpolation=cv2.INTER_AREA) + self.detector.setInputSize((sw, sh)) + _, faces = self.detector.detect(small) + + if faces is not None and len(faces) > 0: + idx = np.argmax(faces[:, 14]) + conf = float(faces[idx, 14]) + + if conf > best_conf: + face_info = faces[idx].copy() + # Scale coordinates back to original image space + inv_scale = 1.0 / scale + face_info[0:4] *= inv_scale # bbox + face_info[4:14] *= inv_scale # landmarks + + best_face = face_info + best_conf = conf + best_scale_name = f"{scale}" + + if best_face is not None and best_scale_name != "1.0": + log.info(f"[Preprocessor] Best detection at scale={best_scale_name} " + f"(conf={best_conf:.2f})") + + return best_face + + +def assess_face_quality( + face: np.ndarray, + landmarks: np.ndarray = None +) -> Dict: + """ + Convenience function to assess face quality. + + Returns: + Quality assessment dictionary with 3 metriks + """ + assessor = FaceQualityAssessment() + return assessor.assess(face, landmarks) + + +# Test function +if __name__ == "__main__": + log.info("Testing FacePreprocessor...") + + preprocessor = FacePreprocessor() + + import sys + if len(sys.argv) > 1: + image = cv2.imread(sys.argv[1]) + if image is not None: + result = preprocessor.detect_and_preprocess(image, for_enrollment=True) + log.info(f"Detection confidence: {result['detection_confidence']:.2f}") + if result['quality']: + q = result['quality'] + log.info(f"Quality Score: {q['quality_score']:.2f} ({q['status']})") + log.info(f" Blur: {q['blur']['score']:.2f} (ok={q['blur']['ok']})") + log.info(f" Brightness: {q['brightness']['score']:.2f} (ok={q['brightness']['ok']})") + log.info(f" Pose: {q['pose']['score']:.2f} (ok={q['pose']['ok']})") diff --git a/facerecognition_presensi/ModelBaru/facecam_realtime.py b/facerecognition_presensi/ModelBaru/facecam_realtime.py new file mode 100644 index 0000000..fa4be47 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/facecam_realtime.py @@ -0,0 +1,226 @@ +""" +Realtime Facecam Testing Tool (Enhanced) +========================================= +Menampilkan facecam live dengan info lengkap: +- Identitas (NIM) +- Fused similarity % +- CNN score & LBPH score +- Quality score +- FPS & threshold +- Akurasi keseluruhan (frame counter) + +Tekan 'q' untuk keluar, 's' untuk screenshot. +""" + +import cv2 +import time +import numpy as np +from datetime import datetime +from enhanced_system import EnhancedFaceRecognitionSystem + + +# ── Konfigurasi tampilan ───────────────────────────────────────────────────── +FONT = cv2.FONT_HERSHEY_SIMPLEX +FONT_SMALL = 0.50 +FONT_MED = 0.62 +THICK = 2 + +GREEN = (50, 205, 50) +RED = (60, 60, 220) +WHITE = (255, 255, 255) +BLACK = (0, 0, 0) +GRAY = (130, 130, 130) + + +def draw_panel(frame, lines, x, y, line_h=22, bg_alpha=0.6): + """Draw semi-transparent info panel.""" + if not lines: + return + max_w = max(cv2.getTextSize(l, FONT, FONT_SMALL, 1)[0][0] for l in lines) + pw = max_w + 16 + ph = len(lines) * line_h + 12 + overlay = frame.copy() + cv2.rectangle(overlay, (x, y), (x + pw, y + ph), (20, 20, 20), -1) + cv2.addWeighted(overlay, bg_alpha, frame, 1 - bg_alpha, 0, frame) + for i, line in enumerate(lines): + cv2.putText(frame, line, + (x + 8, y + 12 + (i + 1) * line_h), + FONT, FONT_SMALL, WHITE, 1, cv2.LINE_AA) + + +def draw_bar(frame, x, y, w, h, pct, color): + """Draw horizontal progress bar (0.0-1.0).""" + cv2.rectangle(frame, (x, y), (x + w, y + h), (50, 50, 50), -1) + fill = int(w * max(0.0, min(1.0, pct))) + if fill > 0: + cv2.rectangle(frame, (x, y), (x + fill, y + h), color, -1) + cv2.rectangle(frame, (x, y), (x + w, y + h), GRAY, 1) + + +def main(): + print("=" * 55) + print(" Realtime Webcam Face Recognition (Post-Fix)") + print("=" * 55) + print("Menginisialisasi sistem...") + + system = EnhancedFaceRecognitionSystem() + gallery_size = system.get_gallery_size() + threshold = system.calibrator.get_threshold() + + print(f" Identitas di gallery : {gallery_size}") + print(f" Threshold : {threshold:.4f}") + print(" Tekan 'q' = keluar | 's' = screenshot") + print("=" * 55) + + cap = cv2.VideoCapture(0) + if not cap.isOpened(): + print("[ERROR] Webcam tidak ditemukan / tidak bisa dibuka.") + return + + fps_buf = [] + last_t = time.time() + total_detect = 0 + recognized_cnt = 0 + unrecognized_cnt = 0 + + while True: + ret, frame = cap.read() + if not ret: + break + + # ── FPS ────────────────────────────────────────────────────────── + now = time.time() + fps_buf.append(1.0 / max(now - last_t, 1e-6)) + if len(fps_buf) > 10: + fps_buf.pop(0) + fps = sum(fps_buf) / len(fps_buf) + last_t = now + + fH, fW = frame.shape[:2] + + # ── Deteksi & preprocess ───────────────────────────────────────── + preproc = system.preprocessor.detect_and_preprocess( + frame, for_enrollment=False) + + if preproc['face'] is not None and preproc.get('bbox') is not None: + x, y, w, h = preproc['bbox'] + aligned = preproc['aligned_face'] + face_img = aligned if aligned is not None else preproc['face'] + quality = preproc['quality'] + q_score = quality['quality_score'] if quality else 0.5 + + # ── Feature extraction ──────────────────────────────────────── + lbph, cnn = system._extract_features(face_img) + lbph = lbph / (lbph.sum() + 1e-7) + cnn = cnn / (np.linalg.norm(cnn) + 1e-7) + + # ── Gallery matching ────────────────────────────────────────── + rec = system.gallery.recognize( + cnn, lbph, + quality_score=q_score, + threshold=threshold, + update_template=False + ) + + fused = rec.similarity + cnn_s = getattr(rec, 'cnn_similarity', fused) + lbph_s = getattr(rec, 'lbph_similarity', fused) + + total_detect += 1 + if rec.is_recognized: + recognized_cnt += 1 + color = GREEN + id_label = rec.nim + pct_txt = f"{fused*100:.1f}%" + else: + unrecognized_cnt += 1 + color = RED + if rec.top_k_results: + top = rec.top_k_results[0] + id_label = f"Unknown ~{top['nim']}" + pct_txt = f"{top['fused']*100:.1f}%" + else: + id_label = "Unknown" + pct_txt = "0.0%" + + # ── Bounding box + header ───────────────────────────────────── + cv2.rectangle(frame, (x, y), (x+w, y+h), color, THICK) + (lw, _), _ = cv2.getTextSize(id_label, FONT, FONT_MED, THICK) + hdr_y = max(0, y - 30) + cv2.rectangle(frame, (x, hdr_y), (x + lw + 10, y), color, -1) + cv2.putText(frame, id_label, + (x + 5, max(14, y - 8)), + FONT, FONT_MED, BLACK, THICK, cv2.LINE_AA) + + # ── Score bars bawah kotak ──────────────────────────────────── + bar_w = min(w, 180) + bar_x = x + bar_y0 = y + h + 6 + if bar_y0 + 50 < fH: + labels = [ + (f"Fused {fused*100:.0f}%", fused, color), + (f"CNN {cnn_s*100:.0f}%", cnn_s, (0, 200, 255)), + (f"LBPH {lbph_s*100:.0f}%", lbph_s, (200, 160, 60)), + ] + for i, (lbl, val, col) in enumerate(labels): + by = bar_y0 + i * 16 + if by + 12 < fH: + draw_bar(frame, bar_x, by, bar_w, 10, val, col) + cv2.putText(frame, lbl, (bar_x + bar_w + 6, by + 9), + FONT, 0.42, col, 1, cv2.LINE_AA) + + # ── Detail panel kanan ──────────────────────────────────────── + px = min(x + w + 8, fW - 210) + detail = [ + f"Status : {'DIKENAL' if rec.is_recognized else 'ASING'}", + f"Fused : {fused*100:.1f}%", + f"CNN : {cnn_s*100:.1f}%", + f"LBPH : {lbph_s*100:.1f}%", + f"Quality: {q_score*100:.0f}%", + f"Thresh : {threshold*100:.1f}%", + ] + draw_panel(frame, detail, px, y) + + # ── Header atas ─────────────────────────────────────────────────── + acc_pct = (recognized_cnt / total_detect * 100) if total_detect > 0 else 0.0 + header = [ + f"FPS : {fps:.1f}", + f"Gallery : {gallery_size} identitas", + f"Terdeteksi: {total_detect} Dikenal: {recognized_cnt}", + f"Akurasi : {acc_pct:.1f}%", + ] + draw_panel(frame, header, 8, 8) + + # ── Footer ─────────────────────────────────────────────────────── + cv2.putText(frame, "q=Keluar s=Screenshot", + (8, fH - 8), FONT, FONT_SMALL, GRAY, 1, cv2.LINE_AA) + + cv2.imshow("Realtime Face Recognition [Post-Fix]", frame) + + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('s'): + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + fname = f"screenshot_{ts}.jpg" + cv2.imwrite(fname, frame) + print(f"[Screenshot] {fname}") + + cap.release() + cv2.destroyAllWindows() + + # ── Laporan akhir ───────────────────────────────────────────────────────── + print("\n" + "=" * 55) + print(" HASIL WEBCAM TEST") + print("=" * 55) + print(f" Frame wajah terdeteksi : {total_detect}") + print(f" Dikenal : {recognized_cnt}") + print(f" Tidak dikenal : {unrecognized_cnt}") + if total_detect > 0: + print(f" Akurasi (frame-level) : {recognized_cnt/total_detect*100:.1f}%") + print("=" * 55) + + +if __name__ == "__main__": + main() + diff --git a/facerecognition_presensi/ModelBaru/faiss_gallery.py b/facerecognition_presensi/ModelBaru/faiss_gallery.py new file mode 100644 index 0000000..2751134 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/faiss_gallery.py @@ -0,0 +1,866 @@ +""" +FAISS Gallery Module (Revisi) +========================= +Enhanced gallery system with: +1. FAISS Index for scalable search +2. Top-3 search for robust matching +3. Incremental Template Update (similarity ≥ 0.98) +4. Gallery Quality - menyimpan metadata kualitas template + +Revisi: +- Top-3 (bukan Top-5) +- Gallery Quality: simpan quality score, tanggal, jumlah registrasi +- Incremental update: similarity ≥ 0.98 + +Author: Skripsi 2026 +""" + +import numpy as np +import pickle +import json +import logging +from typing import Dict, List, Tuple, Optional +from dataclasses import dataclass, field, asdict +from datetime import datetime + +from config import ( + FAISS_CONFIG, TEMPLATE_UPDATE_CONFIG, GALLERY_QUALITY_CONFIG, + DATABASE_CONFIG, CNN_DIM, get_quality_weights, FUSION_CONFIG, + THRESHOLD_CONFIG +) + +log = logging.getLogger(__name__) + + +@dataclass +class IdentityMetadata: + """ + Metadata untuk identitas terdaftar. + + Revisi: Tambahkan Gallery Quality info + - quality_score: Quality score saat enrollment + - enrollment_date: Tanggal registrasi + - num_registrations: Jumlah foto yang digunakan + """ + nim: str + name: Optional[str] = None + + # Gallery Quality Info (Revisi) + enrollment_date: str = "" + quality_score: float = 0.0 + num_registrations: int = 0 + + # Recognition tracking + num_recognitions: int = 0 + last_seen: Optional[str] = None + avg_similarity: float = 0.0 + template_age: int = 0 # Number of updates + + +@dataclass +class RecognitionResult: + """Result of a recognition query.""" + nim: Optional[str] + similarity: float + lbph_similarity: float = 0.0 # Revisi: tambahkan breakdown + cnn_similarity: float = 0.0 + top_k_results: List[Dict] = field(default_factory=list) + is_recognized: bool = False + template_updated: bool = False + message: str = "" + + +class FAISSGallery: + """ + FAISS-based gallery for scalable face recognition. + + Features: + - FAISS Index (Inner Product for cosine similarity) + - Top-3 search (Revisi: dari Top-5) + - Incremental template update (similarity ≥ 0.98) + - Gallery Quality metadata + - Parallel LBPH gallery untuk score-level fusion + """ + + def __init__(self, embedding_dim: int = None): + self.embedding_dim = embedding_dim or CNN_DIM + self.config = FAISS_CONFIG + self.update_config = TEMPLATE_UPDATE_CONFIG + self.gallery_quality_config = GALLERY_QUALITY_CONFIG + + # FAISS index + self.index = None + self._use_faiss = False + + # CNN Gallery (FAISS) — primary centroid + self.identities: Dict[str, np.ndarray] = {} + self.identity_list: List[str] = [] + + # CNN Multi-Template Gallery — semua embedding individual per orang + # Digunakan untuk max-similarity matching (lebih akurat dari centroid saja) + self.cnn_templates: Dict[str, List[np.ndarray]] = {} + + # LBPH Gallery (Revisi: untuk Score-Level Fusion) + self.lbph_gallery: Dict[str, List[np.ndarray]] = {} + + # Metadata + self.metadata: Dict[str, IdentityMetadata] = {} + + # Recognition tracking + self.recognition_counts: Dict[str, int] = {} + self.pending_updates: Dict[str, List[np.ndarray]] = {} + + # Threshold + # Keep one source of truth. The old hard-coded 0.70 conflicted with + # ThresholdCalibrator/config.py (0.58), so the active threshold could + # depend on which gallery file was loaded first. + self.threshold = float(THRESHOLD_CONFIG['initial_threshold']) + + self._init_faiss() + self._load() + + def _init_faiss(self): + """Initialize FAISS index.""" + try: + import faiss + self._faiss = faiss + self._use_faiss = True + log.info("[Gallery] FAISS available") + except ImportError: + log.warning("[Gallery] FAISS not installed. Using numpy fallback.") + self._faiss = None + self._use_faiss = False + + def _create_index(self): + """Create FAISS index.""" + if not self._use_faiss: + return None + + return self._faiss.IndexFlatIP(self.embedding_dim) + + def _load(self): + """Load gallery from disk.""" + metadata_path = DATABASE_CONFIG['metadata'] + if metadata_path.exists(): + try: + with open(metadata_path, 'rb') as f: + data = pickle.load(f) + self.identities = data.get('identities', {}) + self.identity_list = data.get('identity_list', []) + self.lbph_gallery = data.get('lbph_gallery', {}) + self.cnn_templates = data.get('cnn_templates', {}) + + # Convert metadata + metadata_dict = data.get('metadata', {}) + self.metadata = {} + for k, v in metadata_dict.items(): + if isinstance(v, dict): + self.metadata[k] = IdentityMetadata(**v) + else: + self.metadata[k] = v + + self._build_index() + log.info(f"[Gallery] Loaded {len(self.identities)} identities") + except Exception as e: + log.warning(f"[Gallery] Load failed: {e}") + + # Load calibration threshold + calib_path = DATABASE_CONFIG['calibration'] + if calib_path.exists(): + try: + with open(calib_path, 'r') as f: + calib_data = json.load(f) + self.threshold = calib_data.get('optimal_threshold', self.threshold) + log.info(f"[Gallery] Loaded threshold: {self.threshold:.4f}") + except Exception as e: + log.warning(f"[Gallery] Calibration load failed: {e}") + + def _save(self): + """Save gallery to disk.""" + metadata_path = DATABASE_CONFIG['metadata'] + metadata_path.parent.mkdir(parents=True, exist_ok=True) + + data = { + 'identities': self.identities, + 'identity_list': self.identity_list, + 'lbph_gallery': self.lbph_gallery, + 'cnn_templates': self.cnn_templates, + 'metadata': {k: asdict(v) if isinstance(v, IdentityMetadata) else v + for k, v in self.metadata.items()} + } + + with open(metadata_path, 'wb') as f: + pickle.dump(data, f) + + log.info(f"[Gallery] Saved {len(self.identities)} identities") + + def _build_index(self): + """Build or rebuild FAISS index.""" + if not self._use_faiss or len(self.identities) == 0: + return + + self.index = self._create_index() + + embeddings = [] + for nim in self.identity_list: + if nim in self.identities: + embeddings.append(self.identities[nim]) + + if embeddings: + embeddings_array = np.array(embeddings).astype('float32') + self.index.add(embeddings_array) + log.info(f"[Gallery] Index built with {len(embeddings)} vectors") + + def add_identity( + self, + nim: str, + cnn_embedding: np.ndarray, + lbph_embedding: Optional[np.ndarray] = None, + metadata: Optional[Dict] = None, + cnn_templates: Optional[List[np.ndarray]] = None, + lbph_templates: Optional[List[np.ndarray]] = None # Fix 4: terima semua templates + ) -> bool: + """ + Add a new identity to the gallery. + + Args: + nim: Unique identifier + cnn_embedding: CNN embedding vector (512-D) — Quality-Weighted Centroid + lbph_embedding: LBPH embedding vector (640-D) — fallback single template + metadata: Optional metadata + cnn_templates: List of individual CNN embeddings per foto + (untuk multi-template max-similarity matching) + lbph_templates: List of individual LBPH embeddings per foto + (Fix 4: simpan semua — lebih robust untuk variasi pose/cahaya) + """ + if cnn_embedding is None: + log.error(f"[Gallery] No CNN embedding provided for {nim}") + return False + + # Normalize CNN embedding + norm = np.linalg.norm(cnn_embedding) + if norm > 1e-7: + cnn_embedding = cnn_embedding / norm + else: + log.error(f"[Gallery] Invalid CNN embedding for {nim}") + return False + + # Store CNN centroid (primary, untuk FAISS search) + self.identities[nim] = cnn_embedding + if nim not in self.identity_list: + self.identity_list.append(nim) + + # Store CNN multi-templates (untuk max-similarity matching) + if cnn_templates is not None and len(cnn_templates) > 0: + normalized_templates = [] + for tmpl in cnn_templates: + t_norm = np.linalg.norm(tmpl) + if t_norm > 1e-7: + normalized_templates.append(tmpl / t_norm) + self.cnn_templates[nim] = normalized_templates + log.info(f"[Gallery] Stored {len(normalized_templates)} CNN templates for {nim}") + + # Store LBPH — Fix 4: simpan SEMUA templates individual. + # Jika lbph_templates tersedia, gunakan itu (lebih banyak coverage). + # Fallback: gunakan lbph_embedding tunggal (kompatibilitas backward). + if lbph_templates is not None and len(lbph_templates) > 0: + # Simpan semua template individual dengan L1-norm yang konsisten + self.lbph_gallery[nim] = [] + for tmpl in lbph_templates: + tmpl_sum = tmpl.sum() + if tmpl_sum > 1e-7: + self.lbph_gallery[nim].append(tmpl / tmpl_sum) + else: + self.lbph_gallery[nim].append(tmpl) + log.info(f"[Gallery] Stored {len(self.lbph_gallery[nim])} LBPH templates for {nim}") + elif lbph_embedding is not None: + # Backward compat: simpan 1 template saja + self.lbph_gallery[nim] = [] + lbph_sum = lbph_embedding.sum() + if lbph_sum > 1e-7: + lbph_l1 = lbph_embedding / lbph_sum + else: + lbph_l1 = lbph_embedding + self.lbph_gallery[nim].append(lbph_l1) + log.info(f"[Gallery] Stored 1 LBPH template (fallback) for {nim}") + + # Gallery Quality metadata + quality_score = metadata.get('quality_score', 0.0) if metadata else 0.0 + + self.metadata[nim] = IdentityMetadata( + nim=nim, + name=metadata.get('name') if metadata else None, + enrollment_date=datetime.now().isoformat(), + quality_score=quality_score, + num_registrations=metadata.get('num_registrations', 1) if metadata else 1, + num_recognitions=0, + last_seen=None, + avg_similarity=0.0, + template_age=0 + ) + + self.recognition_counts[nim] = 0 + self.pending_updates[nim] = [] + + self._build_index() + self._save() + + n_templates = len(self.cnn_templates.get(nim, [])) + log.info(f"[Gallery] Added identity: {nim} (quality: {quality_score:.2f}, templates: {n_templates})") + return True + + def search_top_k( + self, + query_embedding: np.ndarray, + k: int = None + ) -> List[Tuple[str, float]]: + """ + Search for top-K matches (CNN only). + + Args: + query_embedding: Query embedding + k: Number of results (default: Top-3) + + Returns: + List of (nim, similarity) tuples + """ + k = k or self.config.get('top_k', 3) + + if len(self.identities) == 0: + return [] + + query_vector = np.asarray(query_embedding, dtype=np.float32).reshape(-1) + query_norm = np.linalg.norm(query_vector) + if query_norm > 1e-7: + query_vector = query_vector / query_norm + + # For the normal-sized gallery, search against every stored template + # instead of only the centroid in FAISS. A centroid can be far from a + # valid query when enrollment contains different poses/lighting; in + # that case the correct identity used to be discarded before the + # multi-template max-similarity rule was reached. + # This is still zero-shot recognition: no classifier or retraining is + # introduced, only a more faithful gallery search. + exact_limit = self.config.get('exact_threshold', 1000) + has_templates = any(self.cnn_templates.get(nim) for nim in self.identity_list) + if has_templates and len(self.identities) <= exact_limit: + results = [] + for nim in self.identity_list: + templates = self.cnn_templates.get(nim) or [] + candidates = templates if templates else [self.identities.get(nim)] + similarities = [] + for template in candidates: + if template is None: + continue + template = np.asarray(template, dtype=np.float32).reshape(-1) + template_norm = np.linalg.norm(template) + if template_norm > 1e-7: + template = template / template_norm + similarities.append(float(np.dot(query_vector, template))) + if similarities: + results.append((nim, max(similarities))) + results.sort(key=lambda item: item[1], reverse=True) + return results[:min(k, len(results))] + + query = query_vector.reshape(1, -1).astype('float32') + + if self._use_faiss and self.index is not None: + if self.index.ntotal == 0: + return [] + + distances, indices = self.index.search(query, min(k, self.index.ntotal)) + + results = [] + for dist, idx in zip(distances[0], indices[0]): + if idx >= 0 and idx < len(self.identity_list): + nim = self.identity_list[idx] + similarity = float(dist) + results.append((nim, similarity)) + + return results + else: + # Numpy fallback + results = [] + for nim, emb in self.identities.items(): + similarity = float(np.dot(query_embedding, emb)) + results.append((nim, similarity)) + + results.sort(key=lambda x: x[1], reverse=True) + return results[:k] + + def match_lbph( + self, + query_lbph: np.ndarray + ) -> List[Tuple[str, float]]: + """ + Match query LBPH against LBPH gallery using Histogram Intersection. + + Revisi Akurasi v3: Histogram Intersection instead of cosine similarity. + - Histogram Intersection: Σ min(H1[i], H2[i]) + - Range: [0, 1], higher is better + - More appropriate for histogram-based features like LBPH + - L1-norm normalization (sum = 1) required for proper comparison + + Returns: + List of (nim, similarity) tuples + """ + if len(self.lbph_gallery) == 0: + return [] + + # L1-norm normalization (sum = 1) for histogram intersection + query_sum = query_lbph.sum() + if query_sum > 1e-7: + query_norm = query_lbph / query_sum + else: + query_norm = query_lbph + + results = [] + for nim, templates in self.lbph_gallery.items(): + # Histogram Intersection for each template + similarities = [] + for template in templates: + # Ensure template is L1-normalized + template_sum = template.sum() + if template_sum > 1e-7: + template_norm = template / template_sum + else: + template_norm = template + + # Histogram Intersection: Σ min(H1[i], H2[i]) + intersection = np.sum(np.minimum(query_norm, template_norm)) + similarities.append(intersection) + + # Max similarity across templates (konsisten dengan CNN multi-template) + # Average menarik skor turun karena banyak template augmented yang + # tidak mirip query real → skor genuine turun 10-20%. + max_sim = float(np.max(similarities)) + results.append((nim, max_sim)) + + results.sort(key=lambda x: x[1], reverse=True) + return results + + def recognize( + self, + query_cnn: np.ndarray, + query_lbph: np.ndarray = None, + quality_score: float = 0.5, + threshold: float = None, + update_template: bool = True + ) -> RecognitionResult: + """ + Recognize using Score-Level Fusion (Revisi). + + Args: + query_cnn: Query CNN embedding + query_lbph: Query LBPH embedding (optional) + quality_score: Face quality score + threshold: Similarity threshold + update_template: Whether to update template + + Returns: + RecognitionResult dengan breakdown LBPH dan CNN + """ + threshold = self.threshold if threshold is None else threshold + + if len(self.identities) == 0: + return RecognitionResult(nim=None, similarity=0.0) + + # CNN search + cnn_results = self.search_top_k(query_cnn, k=self.config.get('top_k', 3)) + cnn_dict = {nim: sim for nim, sim in cnn_results} + + # LBPH match (Revisi) + lbph_sim = 0.0 + lbph_results = [] + if query_lbph is not None and len(self.lbph_gallery) > 0: + lbph_results = self.match_lbph(query_lbph) + lbph_dict = {nim: sim for nim, sim in lbph_results} + else: + lbph_dict = {} + + # Score-Level Fusion + best_nim = None + best_fused = 0.0 + best_cnn = 0.0 + + top_k = self.config.get('top_k', 3) + fused_results = [] + + # Candidates: union of CNN and LBPH results + all_nims = set(list(cnn_dict.keys())[:top_k]) + if lbph_dict: + all_nims.update(list(lbph_dict.keys())[:top_k]) + + for nim in all_nims: + # Multi-template CNN matching: gunakan max-similarity rule + # jika multi-template tersedia (lebih akurat dari centroid saja) + if nim in self.cnn_templates and len(self.cnn_templates[nim]) > 0: + template_sims = [float(np.dot(query_cnn, t)) for t in self.cnn_templates[nim]] + cnn_s = max(template_sims) # Max-similarity rule + elif nim in cnn_dict: + cnn_s = cnn_dict[nim] + elif nim in self.identities: + cnn_s = float(np.dot(query_cnn, self.identities[nim])) + else: + cnn_s = 0.0 + lbph_s = lbph_dict.get(nim, 0.0) + + # ───────────────────────────────────────────────────────────────── + # SIGMOID-NORMALIZED SCORE FUSION (Revisi v4 — Final) + # + # Masalah: CNN cosine sim (genuine 0.55-0.80) dan LBPH histogram + # intersection (genuine 0.75-0.90) punya SKALA BERBEDA. Weighted + # average linear menghasilkan skor ~0.75-0.85 yang masih di bawah + # 90% karena kedua skor ada di range menengah. + # + # Solusi: SIGMOID normalization. + # Sigmoid memetakan skor raw ke [0,1] secara non-linear: + # - Skor jauh di atas center → mendekati 1.0 + # - Skor jauh di bawah center → mendekati 0.0 + # - Center = batas keputusan antara genuine dan impostor + # + # Ini memodeli probabilitas bahwa skor tersebut berasal dari + # genuine match, bukan impostor. + # + # CNN: center=0.45 (genuine≥0.55, impostor≤0.35) + # LBPH: center=0.65 (genuine≥0.70, impostor≤0.60) + # k=12: steepness yang memberi separasi baik + # ───────────────────────────────────────────────────────────────── + if query_lbph is not None and lbph_s > 0: + import math + + # Sigmoid parameters + k = 12.0 # steepness + cnn_center = 0.45 # midpoint antara genuine dan impostor CNN + lbph_center = 0.65 # midpoint antara genuine dan impostor LBPH + + # Sigmoid normalization: 1 / (1 + exp(-k * (x - center))) + cnn_norm = 1.0 / (1.0 + math.exp(-k * (cnn_s - cnn_center))) + lbph_norm = 1.0 / (1.0 + math.exp(-k * (lbph_s - lbph_center))) + + # Adaptive weight berdasarkan CNN confidence + if cnn_norm >= 0.75: # CNN confident + w_cnn = 0.60 + elif cnn_norm >= 0.50: # CNN moderate + w_cnn = 0.50 + else: # CNN low → trust LBPH more + w_cnn = 0.40 + w_lbph = 1.0 - w_cnn + + # Fused normalized score + fused_norm = w_cnn * cnn_norm + w_lbph * lbph_norm + + # Rescale ke range [0.40, 1.00] agar threshold 0.58 tetap berlaku + fused = 0.40 + fused_norm * 0.60 + else: + # No LBPH, use CNN only + fused = cnn_s + w_cnn = 1.0 + w_lbph = 0.0 + + fused_results.append({ + 'nim': nim, + 'lbph_sim': lbph_s, + 'cnn_sim': cnn_s, + 'fused': fused, + 'weights': {'cnn': w_cnn, 'lbph': w_lbph} + }) + + # Sort by fused score + fused_results.sort(key=lambda x: x['fused'], reverse=True) + + if fused_results: + best = fused_results[0] + best_nim = best['nim'] + best_fused = best['fused'] + best_cnn = best['cnn_sim'] + lbph_sim = best['lbph_sim'] + + is_recognized = best_fused >= threshold + + result = RecognitionResult( + nim=best_nim if is_recognized else None, + similarity=best_fused, + lbph_similarity=lbph_sim, + cnn_similarity=best_cnn, + top_k_results=fused_results[:top_k], + is_recognized=is_recognized, + template_updated=False + ) + + # Incremental template update (Revisi: 0.98) + if update_template and is_recognized and self.update_config.get('enabled', True): + if self._maybe_update_template(best_nim, query_cnn, best_cnn): + result.template_updated = True + + return result + + def _maybe_update_template( + self, + nim: str, + new_embedding: np.ndarray, + similarity: float + ) -> bool: + """ + Conditionally update a face template. + + Revisi: similarity ≥ 0.98 (lebih konservatif dari 0.90) + """ + cfg = self.update_config + if not cfg.get('enabled', True): + return False + + # Revisi: lebih konservatif + if similarity < cfg.get('similarity_threshold', 0.98): + return False + + if nim not in self.pending_updates: + self.pending_updates[nim] = [] + self.pending_updates[nim].append(new_embedding) + self.recognition_counts[nim] = self.recognition_counts.get(nim, 0) + 1 + + # Check if should apply + batch_size = 5 + min_occurrences = 10 + + if nim in self.pending_updates: + pending = self.pending_updates[nim] + meta = self.metadata.get(nim) + + if len(pending) >= batch_size and meta and meta.num_recognitions >= min_occurrences: + if meta.template_age < 100: + self._apply_template_update(nim, pending) + self.pending_updates[nim] = [] + return True + + return False + + def _apply_template_update( + self, + nim: str, + new_embeddings: List[np.ndarray] + ): + """Apply template update using EMA.""" + alpha = self.update_config.get('ema_alpha', 0.2) + + if nim not in self.identities: + return + + old_template = self.identities[nim] + new_mean = np.mean(new_embeddings, axis=0) + new_mean = new_mean / (np.linalg.norm(new_mean) + 1e-7) + + # EMA + updated = alpha * new_mean + (1 - alpha) * old_template + updated = updated / (np.linalg.norm(updated) + 1e-7) + + self.identities[nim] = updated + + if nim in self.metadata: + self.metadata[nim].template_age += 1 + self.metadata[nim].num_recognitions += len(new_embeddings) + + self._build_index() + self._save() + + log.info(f"[Gallery] Template updated for {nim}") + + def verify_identity( + self, + nim: str, + query_cnn: np.ndarray, + query_lbph: np.ndarray = None, + quality_score: float = 0.5, + threshold: float = None + ) -> Tuple[bool, float]: + """Verify if query matches specific identity.""" + threshold = self.threshold if threshold is None else threshold + + # Case-insensitive NIM lookup: coba exact match → uppercase → search + resolved_nim = None + if nim in self.identities: + resolved_nim = nim + else: + nim_upper = nim.strip().upper() + if nim_upper in self.identities: + resolved_nim = nim_upper + else: + # Fallback: cari case-insensitive di semua keys + for key in self.identities: + if key.upper() == nim_upper: + resolved_nim = key + break + + if resolved_nim is None: + log.warning(f"[Gallery] verify_identity: NIM '{nim}' not found in gallery " + f"(gallery keys: {list(self.identities.keys())[:5]}...)") + return False, 0.0 + + nim = resolved_nim # Gunakan NIM yang resolved + + # Multi-template CNN matching (max-similarity rule) + if nim in self.cnn_templates and len(self.cnn_templates[nim]) > 0: + template_sims = [float(np.dot(query_cnn, t)) for t in self.cnn_templates[nim]] + cnn_sim = max(template_sims) + else: + template = self.identities[nim] + cnn_sim = float(np.dot(query_cnn, template)) + + lbph_sim = 0.0 + if query_lbph is not None and nim in self.lbph_gallery: + # L1-norm normalization for histogram intersection + query_sum = query_lbph.sum() + if query_sum > 1e-7: + query_lbph_norm = query_lbph / query_sum + else: + query_lbph_norm = query_lbph + + lbph_sims = [] + for template in self.lbph_gallery[nim]: + # Ensure template is L1-normalized + template_sum = template.sum() + if template_sum > 1e-7: + template_norm = template / template_sum + else: + template_norm = template + # Histogram Intersection + intersection = np.sum(np.minimum(query_lbph_norm, template_norm)) + lbph_sims.append(intersection) + lbph_sim = float(np.max(lbph_sims)) + + # Sigmoid-normalized fusion (konsisten dengan recognize()) + if query_lbph is not None and lbph_sim > 0: + import math + + k = 12.0 + cnn_center = 0.45 + lbph_center = 0.65 + + cnn_norm = 1.0 / (1.0 + math.exp(-k * (cnn_sim - cnn_center))) + lbph_norm = 1.0 / (1.0 + math.exp(-k * (lbph_sim - lbph_center))) + + if cnn_norm >= 0.75: + w_cnn = 0.60 + elif cnn_norm >= 0.50: + w_cnn = 0.50 + else: + w_cnn = 0.40 + w_lbph = 1.0 - w_cnn + + fused_norm = w_cnn * cnn_norm + w_lbph * lbph_norm + final_sim = 0.40 + fused_norm * 0.60 + else: + final_sim = cnn_sim + + return final_sim >= threshold, final_sim + + def get_identity_info(self, nim: str) -> Optional[IdentityMetadata]: + """Get metadata for an identity (Revisi: include Gallery Quality).""" + return self.metadata.get(nim) + + def get_gallery_stats(self) -> Dict: + """Get gallery statistics including quality info (Revisi).""" + if not self.metadata: + return {'count': 0} + + qualities = [m.quality_score for m in self.metadata.values() if m.quality_score > 0] + return { + 'count': len(self.identities), + 'avg_quality': np.mean(qualities) if qualities else 0.0, + 'min_quality': min(qualities) if qualities else 0.0, + 'max_quality': max(qualities) if qualities else 0.0, + } + + def remove_identity(self, nim: str) -> bool: + """Remove identity from gallery.""" + if nim in self.identities: + del self.identities[nim] + if nim in self.identity_list: + self.identity_list.remove(nim) + if nim in self.cnn_templates: + del self.cnn_templates[nim] + if nim in self.lbph_gallery: + del self.lbph_gallery[nim] + if nim in self.metadata: + del self.metadata[nim] + if nim in self.recognition_counts: + del self.recognition_counts[nim] + if nim in self.pending_updates: + del self.pending_updates[nim] + + self._build_index() + self._save() + log.info(f"[Gallery] Removed identity: {nim}") + return True + + def set_threshold(self, threshold: float): + """Set recognition threshold.""" + self.threshold = threshold + + def get_identity_count(self) -> int: + """Get number of identities.""" + return len(self.identities) + + def list_identities(self) -> List[str]: + """List all identity NIMs.""" + return self.identity_list.copy() + + def clear(self): + """Clear all gallery data.""" + self.identities = {} + self.identity_list = [] + self.cnn_templates = {} + self.lbph_gallery = {} + self.metadata = {} + self.recognition_counts = {} + self.pending_updates = {} + self.index = None + self._build_index() + self._save() + log.info("[Gallery] Cleared") + + +# Test function +if __name__ == "__main__": + log.info("Testing FAISSGallery (Revisi)...") + + gallery = FAISSGallery(embedding_dim=512) + + # Test enrollment with Gallery Quality + for i in range(5): + nim = f"E412533{str(i).zfill(2)}" + cnn_emb = np.random.randn(512).astype(np.float32) + cnn_emb = cnn_emb / np.linalg.norm(cnn_emb) + + lbph_emb = np.random.randn(640).astype(np.float32) + lbph_emb = lbph_emb / np.linalg.norm(lbph_emb) + + gallery.add_identity(nim, cnn_emb, lbph_emb, { + 'quality_score': 0.7 + i * 0.05, + 'num_registrations': 5, + 'name': f'Student {i}' + }) + + # Test recognition with Score-Level Fusion + query_cnn = np.random.randn(512).astype(np.float32) + query_cnn = query_cnn / np.linalg.norm(query_cnn) + query_lbph = np.random.randn(640).astype(np.float32) + query_lbph = query_lbph / np.linalg.norm(query_lbph) + + result = gallery.recognize(query_cnn, query_lbph, quality_score=0.85) + log.info(f"\nRecognition:") + log.info(f" NIM: {result.nim}") + log.info(f" Final Score: {result.similarity:.3f}") + log.info(f" CNN Score: {result.cnn_similarity:.3f}") + log.info(f" LBPH Score: {result.lbph_similarity:.3f}") + + if result.top_k_results: + log.info(f"\nTop-3:") + for i, r in enumerate(result.top_k_results, 1): + log.info(f" {i}. {r['nim']}: fused={r['fused']:.3f}, cnn={r['cnn_sim']:.3f}, lbph={r['lbph_sim']:.3f}") + + # Gallery stats + stats = gallery.get_gallery_stats() + log.info(f"\nGallery Stats:") + log.info(f" Count: {stats['count']}") + log.info(f" Avg Quality: {stats['avg_quality']:.2f}") diff --git a/facerecognition_presensi/ModelBaru/models/__init__.py b/facerecognition_presensi/ModelBaru/models/__init__.py new file mode 100644 index 0000000..fb75757 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/models/__init__.py @@ -0,0 +1 @@ +"""Directory for generic ONNX weights; enrolled identity data is excluded from Git.""" diff --git a/facerecognition_presensi/ModelBaru/models/face_detection_yunet_2023mar.onnx b/facerecognition_presensi/ModelBaru/models/face_detection_yunet_2023mar.onnx new file mode 100644 index 0000000..f9beb30 Binary files /dev/null and b/facerecognition_presensi/ModelBaru/models/face_detection_yunet_2023mar.onnx differ diff --git a/facerecognition_presensi/ModelBaru/models/mobilefacenet.onnx b/facerecognition_presensi/ModelBaru/models/mobilefacenet.onnx new file mode 100644 index 0000000..2c9df77 Binary files /dev/null and b/facerecognition_presensi/ModelBaru/models/mobilefacenet.onnx differ diff --git a/facerecognition_presensi/ModelBaru/quality_aware_engine.py b/facerecognition_presensi/ModelBaru/quality_aware_engine.py new file mode 100644 index 0000000..dbaf2a4 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/quality_aware_engine.py @@ -0,0 +1,473 @@ +""" +Quality-Aware Engine for Face Recognition System +================================================ +Modul komprehensif untuk Penilaian Kualitas Wajah (Face Image Quality Assessment / FIQA), +Penyaringan Kualitas (Quality Gatekeeper), Fusi Skor Sadar-Kualitas (Quality-Aware Score Fusion), +serta Ambang Batas Adaptif (Quality-Aware Adaptive Thresholding). + +Didesain selaras dengan standar biometrika ISO/IEC 29794-5 dan NIST FRVT, +terintegrasi langsung dengan pipeline ModelBaru (YuNet + MobileFaceNet + LBPH). + +Author: Skripsi 2026 +""" + +import math +import logging +from typing import Dict, Tuple, Optional, Any, List +from dataclasses import dataclass, field +import numpy as np +import cv2 + +log = logging.getLogger(__name__) + + +@dataclass +class QualityMetrics: + """Struktur data detail untuk seluruh metrik kualitas wajah.""" + blur_score: float # Skor ketajaman [0, 1] + blur_variance: float # Nilai varians Laplacian asli + blur_threshold: float # Threshold Laplacian adaptif yang digunakan + blur_ok: bool # Status ketajaman + + brightness_score: float # Skor pencahayaan [0, 1] + mean_brightness: float # Rata-rata intensitas piksel (0 - 255) + brightness_ok: bool # Status pencahayaan + + pose_score: float # Skor simetri / pose [0, 1] + yaw_deg: float # Estimasi sudut yaw (derajat) + pitch_deg: float # Estimasi sudut pitch (derajat) + pose_ok: bool # Status pose + + resolution_score: float # Skor resolusi / scale [0, 1] + ipd_pixels: float # Inter-Pupillary Distance (jarak antar pupil/mata) + resolution_ok: bool # Status resolusi + + quality_score: float # Unified Quality Score Q in [0, 1] + status: str # 'good', 'acceptable', 'poor' + metriks_passed: int # Jumlah metrik lolos (0 - 4) + feedback_messages: List[str] = field(default_factory=list) # Instruksi saran untuk user + + +@dataclass +class FusionResult: + """Struktur data hasil Quality-Aware Score Fusion.""" + fused_score: float # Skor kemiripan gabungan akhir [0, 1] + w_cnn: float # Bobot yang dialokasikan untuk CNN + w_lbph: float # Bobot yang dialokasikan untuk LBPH + cnn_raw: float # Skor kemiripan mentah CNN + lbph_raw: float # Skor kemiripan mentah LBPH + cnn_norm: float # Skor CNN setelah normalisasi sigmoid probabilitas + lbph_norm: float # Skor LBPH setelah normalisasi sigmoid probabilitas + quality_score: float # Kualitas citra yang digunakan sebagai acuan + adaptive_threshold: float # Ambang batas keputusan yang disesuaikan kualitas + is_recognized: bool # Keputusan akhir verifikasi / rekognisi + + +class QualityAwareEngine: + """ + Engine terpadu untuk Quality-Aware Face Recognition. + + Menyediakan fungsionalitas: + 1. Multi-metric Quality Assessment (Blur adaptif, Brightness, Pose, Resolusi) + 2. Quality Gatekeeper (Penyaring kelayakan enroll & rekognisi) + 3. Dynamic Weight Allocation (Alokasi bobot multimodal berdasarkan kualitas) + 4. Sigmoid-Quality Modulated Score Fusion (Fusi skor non-linear terkalibrasi) + 5. Adaptive Decision Thresholding (Penyesuaian batas keputusan berbasis kualitas) + """ + + def __init__( + self, + # Konfigurasi Blur (Laplacian) + blur_base_threshold: float = 30.0, + # Konfigurasi Brightness + brightness_min: float = 20.0, + brightness_max: float = 230.0, + brightness_optimal: float = 125.0, + # Konfigurasi Pose (5 landmark) + yaw_threshold: float = 15.0, + pitch_threshold: float = 15.0, + # Konfigurasi Resolusi (Inter-pupillary distance) + ipd_min_threshold: float = 28.0, + # Konfigurasi Gatekeeper + enrollment_threshold: float = 0.40, + recognition_threshold: float = 0.50, + # Konfigurasi Bobot Fusi + base_w_cnn: float = 0.75, + alpha_cnn: float = 0.20, + w_cnn_min: float = 0.60, + w_cnn_max: float = 0.90, + # Konfigurasi Ambang Batas Verifikasi + base_verification_threshold: float = 0.58 + ): + self.blur_base_threshold = blur_base_threshold + self.brightness_min = brightness_min + self.brightness_max = brightness_max + self.brightness_optimal = brightness_optimal + self.yaw_threshold = yaw_threshold + self.pitch_threshold = pitch_threshold + self.ipd_min_threshold = ipd_min_threshold + self.enrollment_threshold = enrollment_threshold + self.recognition_threshold = recognition_threshold + self.base_w_cnn = base_w_cnn + self.alpha_cnn = alpha_cnn + self.w_cnn_min = w_cnn_min + self.w_cnn_max = w_cnn_max + self.base_verification_threshold = base_verification_threshold + + # Geometri acuan standar wajah frontal (reference points 112x112) + # Digunakan untuk menghitung deviasi pitch anatomis yang invarian + self.ref_points = np.array([ + [38.2946, 51.6963], # Mata kiri + [73.5318, 51.5014], # Mata kanan + [56.0252, 71.7366], # Hidung + [41.5493, 92.3655], # Sudut mulut kiri + [70.7299, 92.2041] # Sudut mulut kanan + ], dtype=np.float32) + + ref_eye_dist = np.linalg.norm(self.ref_points[1] - self.ref_points[0]) + ref_eye_mid_y = (self.ref_points[0][1] + self.ref_points[1][1]) / 2.0 + ref_mouth_mid_y = (self.ref_points[3][1] + self.ref_points[4][1]) / 2.0 + + self.ref_eye_dist = ref_eye_dist + self.ref_eye_nose_ratio = (self.ref_points[2][1] - ref_eye_mid_y) / ref_eye_dist + self.ref_nose_mouth_ratio = (ref_mouth_mid_y - self.ref_points[2][1]) / ref_eye_dist + + # ========================================================================= + # 1. FACE IMAGE QUALITY ASSESSMENT (FIQA) + # ========================================================================= + + def assess_blur(self, face: np.ndarray, brightness_hint: Optional[float] = None) -> Tuple[bool, float, float, float]: + """ + Menilai ketajaman citra wajah menggunakan Laplacian Variance dengan + Threshold Adaptif berbasis intensitas pencahayaan lokal. + """ + if len(face.shape) == 3: + gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) + else: + gray = face.copy() + + # Normalisasi ukuran untuk komputasi Laplacian yang konsisten + gray_resized = cv2.resize(gray, (128, 128)) + variance = float(cv2.Laplacian(gray_resized, cv2.CV_64F).var()) + + # Gunakan brightness hint jika disediakan, atau hitung mean piksel + b = brightness_hint if brightness_hint is not None else float(np.mean(gray)) + + # Threshold adaptif: pada kondisi gelap, kontras lokal menurun alami + if b < 40.0: + threshold = 12.0 + elif b < 60.0: + threshold = 18.0 + elif b < 80.0: + threshold = 22.0 + else: + threshold = self.blur_base_threshold # Default 30.0 + + if variance < threshold: + score = float(np.clip(variance / max(threshold, 1e-5), 0.0, 1.0)) + is_ok = False + else: + score = float(min(1.0, variance / max(threshold * 2.0, 1e-5))) + is_ok = True + + return is_ok, score, variance, threshold + + def assess_brightness(self, face: np.ndarray) -> Tuple[bool, float, float]: + """ + Menilai tingkat pencahayaan wajah dengan toleransi kondisi indoor (smooth curve). + """ + if len(face.shape) == 3: + gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) + else: + gray = face.copy() + + mean_val = float(np.mean(gray)) + + if mean_val < self.brightness_min: + # Underexposure / low-light: curve bertahap (tidak langsung 0) + score = 0.3 + 0.7 * (mean_val / max(self.brightness_min, 1e-5)) + score = float(np.clip(score, 0.0, 1.0)) + is_ok = False + elif mean_val > self.brightness_max: + # Overexposure + score = float(np.clip(self.brightness_max / max(mean_val, 1e-5), 0.0, 1.0)) + is_ok = False + else: + # Optimal zone + half_range = (self.brightness_max - self.brightness_min) / 2.0 + deviation = abs(mean_val - self.brightness_optimal) / max(half_range, 1e-5) + score = float(max(0.70, 1.0 - 0.30 * deviation)) + is_ok = True + + return is_ok, score, mean_val + + def assess_pose(self, landmarks: Optional[np.ndarray]) -> Tuple[bool, float, float, float]: + """ + Menilai pose kepala (sudut rotasi horizontal yaw & rotasi vertikal pitch) + menggunakan 5 titik facial landmark relatif terhadap template frontal. + """ + if landmarks is None or len(landmarks) < 5: + # Fallback jika landmark tidak terdeteksi + return True, 0.70, 0.0, 0.0 + + left_eye = landmarks[0] + right_eye = landmarks[1] + nose = landmarks[2] + left_mouth = landmarks[3] + right_mouth = landmarks[4] + + eye_dist = float(np.linalg.norm(right_eye - left_eye)) + if eye_dist < 1.0: + return True, 0.70, 0.0, 0.0 + + eye_mid = (left_eye + right_eye) / 2.0 + mouth_mid = (left_mouth + right_mouth) / 2.0 + + # Yaw (Rotasi horizontal) + yaw = float(abs(nose[0] - eye_mid[0]) / eye_dist * 45.0) + + # Pitch (Rotasi vertikal menggunakan deviasi rasio anatomis) + eye_nose_ratio = float((nose[1] - eye_mid[1]) / eye_dist) + nose_mouth_ratio = float((mouth_mid[1] - nose[1]) / eye_dist) + + dev_eye_nose = abs(eye_nose_ratio - self.ref_eye_nose_ratio) + dev_nose_mouth = abs(nose_mouth_ratio - self.ref_nose_mouth_ratio) + pitch = float((dev_eye_nose + dev_nose_mouth) * 22.5) + + yaw_score = max(0.0, 1.0 - yaw / self.yaw_threshold) + pitch_score = max(0.0, 1.0 - pitch / self.pitch_threshold) + pose_score = float((yaw_score + pitch_score) / 2.0) + + is_ok = (yaw <= self.yaw_threshold) and (pitch <= self.pitch_threshold) + return is_ok, pose_score, yaw, pitch + + def assess_resolution(self, landmarks: Optional[np.ndarray], face_shape: Tuple[int, int]) -> Tuple[bool, float, float]: + """ + Menilai resolusi wajah berdasarkan Inter-Pupillary Distance (IPD) dalam piksel. + Standar ISO/IEC 29794-5 mensyaratkan IPD memadai (minimal ~30 piksel untuk rekognisi andal). + """ + if landmarks is not None and len(landmarks) >= 2: + ipd = float(np.linalg.norm(landmarks[1] - landmarks[0])) + else: + ipd = float(face_shape[1] * 0.35) # Estimasi 35% lebar crop wajah + + if ipd >= self.ipd_min_threshold * 1.5: + score = 1.0 + is_ok = True + elif ipd >= self.ipd_min_threshold: + score = 0.75 + 0.25 * ((ipd - self.ipd_min_threshold) / (self.ipd_min_threshold * 0.5)) + is_ok = True + else: + score = float(max(0.2, ipd / max(self.ipd_min_threshold, 1e-5))) + is_ok = False + + return is_ok, float(np.clip(score, 0.0, 1.0)), ipd + + def assess(self, face: np.ndarray, landmarks: Optional[np.ndarray] = None) -> QualityMetrics: + """ + Mengevaluasi seluruh metrik kualitas secara holistik. + + Formula skor gabungan: + Q = 0.35 * blur_score + 0.30 * brightness_score + 0.25 * pose_score + 0.10 * resolution_score + """ + # 1. Brightness + b_ok, b_score, mean_b = self.assess_brightness(face) + + # 2. Blur (dengan brightness hint) + blur_ok, blur_score, variance, blur_th = self.assess_blur(face, brightness_hint=mean_b) + + # 3. Pose + pose_ok, pose_score, yaw, pitch = self.assess_pose(landmarks) + + # 4. Resolution + res_ok, res_score, ipd = self.assess_resolution(landmarks, face.shape[:2]) + + # Bobot komposit kualitas + w_blur = 0.35 + w_bright = 0.30 + w_pose = 0.25 + w_res = 0.10 + + composite_q = ( + w_blur * blur_score + + w_bright * b_score + + w_pose * pose_score + + w_res * res_score + ) + composite_q = float(np.clip(composite_q, 0.0, 1.0)) + + metriks_passed = int(blur_ok) + int(b_ok) + int(pose_ok) + int(res_ok) + + # Penentuan status kategori kualitas + if metriks_passed >= 3 and composite_q >= 0.60: + status = 'good' + elif metriks_passed >= 2 and composite_q >= 0.40: + status = 'acceptable' + else: + status = 'poor' + + # Generate panduan instruksi (feedback) untuk pengguna + feedback = [] + if not blur_ok: + feedback.append("Gambar buram atau fokus lensa tidak tajam. Mohon stabilkan kamera.") + if mean_b < self.brightness_min: + feedback.append("Pencahayaan terlalu redup / gelap. Mohon hadapkan wajah ke arah cahaya.") + elif mean_b > self.brightness_max: + feedback.append("Pencahayaan terlalu terang / backlight (overexposed).") + if not pose_ok: + if yaw > self.yaw_threshold: + feedback.append(f"Wajah menoleh ke samping ({yaw:.1f}°). Mohon hadap lurus ke kamera.") + if pitch > self.pitch_threshold: + feedback.append(f"Wajah menunduk / menengadah ({pitch:.1f}°). Mohon pandang sejajar kamera.") + if not res_ok: + feedback.append("Posisi wajah terlalu jauh dari kamera. Mohon dekati layar.") + + if not feedback: + feedback.append("Kualitas wajah prima, siap untuk verifikasi.") + + return QualityMetrics( + blur_score=blur_score, + blur_variance=variance, + blur_threshold=blur_th, + blur_ok=blur_ok, + brightness_score=b_score, + mean_brightness=mean_b, + brightness_ok=b_ok, + pose_score=pose_score, + yaw_deg=yaw, + pitch_deg=pitch, + pose_ok=pose_ok, + resolution_score=res_score, + ipd_pixels=ipd, + resolution_ok=res_ok, + quality_score=composite_q, + status=status, + metriks_passed=metriks_passed, + feedback_messages=feedback + ) + + # ========================================================================= + # 2. QUALITY GATEKEEPER + # ========================================================================= + + def check_gatekeeper(self, quality: QualityMetrics, mode: str = 'recognition') -> Tuple[bool, str]: + """ + Memeriksa apakah citra wajah memenuhi standar ambang batas kualitas minimum. + + Args: + quality: Objek QualityMetrics hasil evaluasi + mode: 'enrollment' (Q >= 0.40) atau 'recognition' (Q >= 0.50) + """ + required_th = self.enrollment_threshold if mode == 'enrollment' else self.recognition_threshold + if quality.quality_score >= required_th: + return True, f"Lolos Quality Gatekeeper ({quality.status.upper()} - Q={quality.quality_score:.2f})" + else: + reasons = "; ".join(quality.feedback_messages) + return False, f"Ditolak Quality Gatekeeper (Q={quality.quality_score:.2f} < {required_th:.2f}). Alasan: {reasons}" + + # ========================================================================= + # 3. QUALITY-AWARE SCORE FUSION & DYNAMIC WEIGHTING + # ========================================================================= + + def compute_quality_weights(self, quality_score: float) -> Tuple[float, float]: + """ + Menghitung alokasi bobot multimodal secara dinamis berdasarkan skor kualitas citra Q. + + Formula: + w_CNN = clip(0.75 + 0.20 * (Q - 0.50), min=0.60, max=0.90) + w_LBPH = 1.0 - w_CNN + + Intuisi Biometrik: + - Jika citra sangat tajam dan prima (Q=1.00), CNN diberi bobot dominan (0.90). + - Jika citra terdegradasi (Q=0.20), embedding semantik CNN rentan terdistorsi, + sehingga LBPH (tekstur lokal mikro) dinaikkan bobotnya hingga 0.40 sebagai kompensator. + """ + q_clamped = max(0.0, min(1.0, quality_score)) + w_cnn = self.base_w_cnn + self.alpha_cnn * (q_clamped - 0.50) + w_cnn = float(np.clip(w_cnn, self.w_cnn_min, self.w_cnn_max)) + w_lbph = float(round(1.0 - w_cnn, 4)) + return w_cnn, w_lbph + + def compute_adaptive_threshold(self, quality_score: float) -> float: + """ + Menghitung ambang batas verifikasi adaptif berdasarkan kualitas citra: + tau(Q) = tau_base + delta_tau * (1.0 - Q) * 0.10 + Menghindari lonjakan False Acceptance Rate (FAR) ketika citra input berkualitas rendah. + """ + q_clamped = max(0.0, min(1.0, quality_score)) + delta = (1.0 - q_clamped) * 0.05 + return float(self.base_verification_threshold + delta) + + def fuse( + self, + cnn_similarity: float, + lbph_similarity: float, + quality_score: float, + use_sigmoid_normalization: bool = True + ) -> FusionResult: + """ + Melakukan Quality-Aware Score-Level Fusion. + + Args: + cnn_similarity: Cosine similarity dari MobileFaceNet [-1.0, 1.0] + lbph_similarity: Histogram intersection dari LBPH [0.0, 1.0] + quality_score: Kualitas citra wajah [0.0, 1.0] + use_sigmoid_normalization: Menggunakan pemetaan sigmoid non-linear + """ + w_cnn, w_lbph = self.compute_quality_weights(quality_score) + adaptive_th = self.compute_adaptive_threshold(quality_score) + + if not use_sigmoid_normalization: + # Linear fusion sederhana + fused_score = w_cnn * cnn_similarity + w_lbph * lbph_similarity + cnn_norm = cnn_similarity + lbph_norm = lbph_similarity + else: + # Sigmoid-normalized fusion (Revisi v4 Final) + # Menyelaraskan distribusi skor CNN (center 0.45) dan LBPH (center 0.65) + k = 12.0 + cnn_center = 0.45 + lbph_center = 0.65 + + cnn_norm = 1.0 / (1.0 + math.exp(-k * (cnn_similarity - cnn_center))) + lbph_norm = 1.0 / (1.0 + math.exp(-k * (lbph_similarity - lbph_center))) + + # Penyesuaian halus bobot berdasarkan kualitas & konfidensi CNN + if cnn_norm >= 0.75: + w_cnn_adj = min(self.w_cnn_max, w_cnn + 0.05) + elif cnn_norm < 0.40: + w_cnn_adj = max(self.w_cnn_min, w_cnn - 0.05) + else: + w_cnn_adj = w_cnn + + w_lbph_adj = 1.0 - w_cnn_adj + + fused_norm = w_cnn_adj * cnn_norm + w_lbph_adj * lbph_norm + # Rescale ke domain operasional [0.40, 1.00] + fused_score = 0.40 + fused_norm * 0.60 + w_cnn, w_lbph = w_cnn_adj, w_lbph_adj + + is_recognized = (fused_score >= adaptive_th) + + return FusionResult( + fused_score=fused_score, + w_cnn=w_cnn, + w_lbph=w_lbph, + cnn_raw=cnn_similarity, + lbph_raw=lbph_similarity, + cnn_norm=cnn_norm, + lbph_norm=lbph_norm, + quality_score=quality_score, + adaptive_threshold=adaptive_th, + is_recognized=is_recognized + ) + + +if __name__ == "__main__": + # Self-test sederhana + engine = QualityAwareEngine() + print("Quality-Aware Engine initialized successfully!") + w_c, w_l = engine.compute_quality_weights(0.85) + print(f"Sample weights at Q=0.85 -> w_CNN: {w_c:.2f}, w_LBPH: {w_l:.2f}") + w_c, w_l = engine.compute_quality_weights(0.25) + print(f"Sample weights at Q=0.25 -> w_CNN: {w_c:.2f}, w_LBPH: {w_l:.2f}") diff --git a/facerecognition_presensi/ModelBaru/recognize_model.py b/facerecognition_presensi/ModelBaru/recognize_model.py new file mode 100644 index 0000000..92c9876 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/recognize_model.py @@ -0,0 +1,448 @@ +""" +Recognition and Accuracy Test Tool +================================== +Pakai model utama ModelBaru: EnhancedFaceRecognitionSystem +(LBPH + MobileFaceNet score-level fusion). + +Mode: +- Single image recognition +- Single image verification against expected NIM +- Webcam live recognition / sampled accuracy +- Folder evaluation, where subfolder name is the expected NIM +""" + +import argparse +import os +import sys +import time +from pathlib import Path +from typing import Dict, Iterable, List, Optional + +import cv2 +import logging + +_CURRENT_FILE = Path(__file__).resolve() +_MODELBARU_DIR = _CURRENT_FILE.parent +sys.path.insert(0, str(_MODELBARU_DIR)) + +from config import get_quality_weights +from threshold_calibrator import CalibrationResult +from enhanced_system import EnhancedFaceRecognitionSystem + +os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" +logging.basicConfig(level=logging.INFO, format="%(message)s") +log = logging.getLogger(__name__) + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} + + +def _same_id(left: Optional[str], right: Optional[str]) -> bool: + return bool(left and right and str(left).strip().lower() == str(right).strip().lower()) + + +def _image_files(paths: Iterable[Path]) -> List[Path]: + images = [] + for path in paths: + if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS: + images.append(path) + return sorted(images) + + +def apply_test_threshold(system: EnhancedFaceRecognitionSystem, threshold: Optional[float]): + """Override threshold only for this test process.""" + if threshold is None: + return + + threshold = float(threshold) + system.calibrator.calibration = CalibrationResult( + optimal_threshold=threshold, + eer_threshold=threshold, + eer=0.0, + far_at_optimal=0.0, + frr_at_optimal=0.0, + roc_points=0, + calibration_date="temporary_test_override", + ) + system.gallery.set_threshold(threshold) + log.info(f"[Test] Temporary threshold override: {threshold:.4f}") + + +def best_candidate(result) -> Optional[Dict]: + if result and result.top_k_results: + return result.top_k_results[0] + return None + + +def _print_quality(system: EnhancedFaceRecognitionSystem, image, verbose: bool) -> Dict: + preproc = system.preprocessor.detect_and_preprocess(image, for_enrollment=False) + if preproc["face"] is None: + log.warning("[!] No face detected") + return {} + + quality = preproc.get("quality") or {} + if verbose and quality: + weights = get_quality_weights(float(quality.get("quality_score", 0.5))) + log.info("\nQuality") + log.info(f" Overall : {quality['quality_score']:.3f} ({quality['status']})") + log.info(f" Blur : {quality['blur']['score']:.3f} ok={quality['blur']['ok']}") + log.info(f" Brightness : {quality['brightness']['score']:.3f} ok={quality['brightness']['ok']}") + log.info(f" Pose : {quality['pose']['score']:.3f} ok={quality['pose']['ok']}") + log.info("\nFusion Weights") + log.info(f" CNN : {weights['mobilefacenet']:.3f}") + log.info(f" LBPH : {weights['lbph']:.3f}") + + return preproc + + +def recognize_image( + system: EnhancedFaceRecognitionSystem, + image_path: str, + expected: Optional[str] = None, + verbose: bool = False, + update_template: bool = False, +) -> Dict: + """Recognize one image and optionally verify against expected NIM.""" + image = cv2.imread(str(image_path)) + if image is None: + raise ValueError(f"Cannot read image: {image_path}") + + log.info("\n" + "=" * 70) + log.info(f"IMAGE TEST: {image_path}") + log.info("=" * 70) + + preproc = _print_quality(system, image, verbose) + if preproc.get("face") is None: + return { + "image": str(image_path), + "expected": expected, + "predicted": None, + "recognized": False, + "score": 0.0, + "correct": False if expected else None, + "verified": False if expected else None, + } + + result = system.recognize(image, update_template=update_template) + predicted = result.nim if result.is_recognized else None + best = best_candidate(result) + correct = _same_id(predicted, expected) if expected else None + + verified = None + verify_similarity = None + if expected: + verified, verify_similarity = system.verify(image, expected) + + log.info("\nResult") + log.info(f" Expected : {expected or '-'}") + log.info(f" Predicted : {predicted or 'Unknown'}") + if not predicted and best: + log.info(f" Best below : {best['nim']} ({best['fused']:.4f})") + log.info(f" Final : {result.similarity:.4f}") + log.info(f" CNN : {result.cnn_similarity:.4f}") + log.info(f" LBPH : {result.lbph_similarity:.4f}") + log.info(f" Threshold : {system.calibrator.get_threshold():.4f}") + if expected: + log.info(f" Correct : {correct}") + log.info(f" Verify : {verified} ({verify_similarity:.4f})") + + if result.top_k_results: + log.info("\nTop-3") + for i, item in enumerate(result.top_k_results[:3], 1): + marker = " <- expected" if expected and _same_id(item["nim"], expected) else "" + log.info( + f" {i}. {item['nim']}: {item['fused']:.4f} " + f"(CNN={item['cnn_sim']:.3f}, LBPH={item['lbph_sim']:.3f}){marker}" + ) + + return { + "image": str(image_path), + "expected": expected, + "predicted": predicted, + "recognized": bool(result.is_recognized), + "score": float(result.similarity), + "cnn": float(result.cnn_similarity), + "lbph": float(result.lbph_similarity), + "correct": correct, + "verified": verified, + "verify_similarity": verify_similarity, + } + + +def verify_identity(system: EnhancedFaceRecognitionSystem, image_path: str, nim: str) -> Dict: + """Verify one image against a specific NIM.""" + return recognize_image(system, image_path, expected=nim, verbose=True, update_template=False) + + +def evaluate_dataset( + system: EnhancedFaceRecognitionSystem, + dataset_dir: str, + limit_per_identity: int = 0, +) -> Dict: + """ + Evaluate a folder where each direct subfolder is one NIM. + + Example: + face_mahasiswa/E41253330/foto_001.jpg + face_mahasiswa/E41253355/foto_001.jpg + """ + root = Path(dataset_dir) + if not root.exists(): + raise ValueError(f"Dataset folder not found: {dataset_dir}") + + samples = [] + for identity_dir in sorted(root.iterdir()): + if not identity_dir.is_dir(): + continue + images = _image_files(identity_dir.iterdir()) + if limit_per_identity > 0: + images = images[:limit_per_identity] + for image_path in images: + samples.append((identity_dir.name, image_path)) + + if not samples: + raise ValueError(f"No images found in: {dataset_dir}") + + total = len(samples) + correct = 0 + verified_ok = 0 + unknown = 0 + wrong = 0 + rows = [] + + log.info("\n" + "=" * 70) + log.info(f"DATASET EVALUATION: {dataset_dir}") + log.info(f"Samples: {total}") + log.info(f"Threshold: {system.calibrator.get_threshold():.4f}") + log.info("=" * 70) + + for index, (expected, image_path) in enumerate(samples, 1): + image = cv2.imread(str(image_path)) + if image is None: + log.warning(f"[{index}/{total}] unreadable: {image_path}") + continue + + result = system.recognize(image, update_template=False) + predicted = result.nim if result.is_recognized else None + best = best_candidate(result) + is_correct = _same_id(predicted, expected) + verified, verify_similarity = system.verify(image, expected) + + correct += 1 if is_correct else 0 + verified_ok += 1 if verified else 0 + unknown += 1 if predicted is None else 0 + wrong += 1 if predicted is not None and not is_correct else 0 + + rows.append({ + "expected": expected, + "predicted": predicted, + "score": float(result.similarity), + "verified": bool(verified), + "verify_similarity": float(verify_similarity), + "image": str(image_path), + }) + + status = "OK" if is_correct else ("UNKNOWN" if predicted is None else "WRONG") + best_text = "" + if predicted is None and best: + best_text = f" best={best['nim']}({best['fused']:.4f})" + log.info( + f"[{index:03d}/{total:03d}] {status:<7} " + f"expected={expected:<12} predicted={predicted or 'Unknown':<12} " + f"score={result.similarity:.4f} verify={verify_similarity:.4f}{best_text}" + ) + + accuracy = correct / total if total else 0.0 + verify_accuracy = verified_ok / total if total else 0.0 + + log.info("\n" + "=" * 70) + log.info("SUMMARY") + log.info("=" * 70) + log.info(f" Top-1 accuracy : {accuracy:.2%} ({correct}/{total})") + log.info(f" Verify accuracy : {verify_accuracy:.2%} ({verified_ok}/{total})") + log.info(f" Unknown : {unknown}") + log.info(f" Wrong identity : {wrong}") + log.info(f" Threshold : {system.calibrator.get_threshold():.4f}") + + return { + "total": total, + "correct": correct, + "accuracy": accuracy, + "verified_ok": verified_ok, + "verify_accuracy": verify_accuracy, + "unknown": unknown, + "wrong": wrong, + "rows": rows, + } + + +def webcam_recognition( + system: EnhancedFaceRecognitionSystem, + expected: Optional[str] = None, + camera_index: int = 0, + samples: int = 0, + update_template: bool = False, +): + """ + Webcam live recognition. + + Press: + - s: save one sample into accuracy counter + - q: quit + """ + log.info("\n" + "=" * 70) + log.info("WEBCAM TEST") + log.info("=" * 70) + + cap = cv2.VideoCapture(camera_index) + if not cap.isOpened(): + log.error(f"[!] Cannot open webcam index {camera_index}") + return + + log.info(f"Gallery : {system.get_gallery_size()} identities") + log.info(f"Threshold : {system.calibrator.get_threshold():.4f}") + log.info(f"Expected : {expected or '-'}") + log.info("Controls : press 's' to sample, 'q' to quit") + + sampled = 0 + correct = 0 + last_result = None + last_text = "No sample" + last_frame_time = 0.0 + + while True: + ret, frame = cap.read() + if not ret: + break + + now = time.time() + if now - last_frame_time > 0.25: + last_result = system.recognize(frame, update_template=update_template) + last_frame_time = now + + result = last_result + if result is not None: + predicted = result.nim if result.is_recognized else None + best = best_candidate(result) + is_correct = _same_id(predicted, expected) if expected else None + color = (0, 255, 0) if is_correct else (0, 255, 255) + if expected and not is_correct: + color = (0, 0, 255) + + if predicted: + text = f"{predicted} score={result.similarity:.2f}" + elif best: + text = f"Unknown best={best['nim']} score={best['fused']:.2f}" + else: + text = f"Unknown score={result.similarity:.2f}" + cv2.putText(frame, text, (12, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) + + if expected: + verified, verify_similarity = system.verify(frame, expected) + verify_text = f"verify {expected}: {verify_similarity:.2f} {'MATCH' if verified else 'NO'}" + cv2.putText(frame, verify_text, (12, 120), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 1) + + if expected and sampled: + acc = correct / sampled + last_text = f"Samples={sampled} Correct={correct} Acc={acc:.1%}" + elif samples: + last_text = f"Samples={sampled}/{samples}" + + cv2.putText(frame, last_text, (12, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2) + cv2.putText(frame, "s=sample q=quit", (12, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1) + cv2.imshow("ModelBaru Accuracy Test", frame) + + key = cv2.waitKey(1) & 0xFF + if key == ord("q"): + break + if key == ord("s") and result is not None: + sampled += 1 + predicted = result.nim if result.is_recognized else None + best = best_candidate(result) + is_correct = _same_id(predicted, expected) if expected else None + if is_correct: + correct += 1 + log.info( + f"[sample {sampled}] expected={expected or '-'} " + f"predicted={predicted or 'Unknown'} score={result.similarity:.4f} " + f"best={best['nim'] if best else '-'} " + f"best_score={best['fused'] if best else 0.0:.4f} correct={is_correct}" + ) + if samples > 0 and sampled >= samples: + break + + cap.release() + cv2.destroyAllWindows() + + if expected and sampled: + log.info("\nWebcam Accuracy") + log.info(f" Accuracy : {correct / sampled:.2%} ({correct}/{sampled})") + + +def show_gallery(system: EnhancedFaceRecognitionSystem): + log.info("\n" + "=" * 70) + log.info("GALLERY") + log.info("=" * 70) + log.info(f"Identities : {system.get_gallery_size()}") + log.info(f"Threshold : {system.calibrator.get_threshold():.4f}") + metadata_path = _MODELBARU_DIR / "models" / "face_metadata.pkl" + if metadata_path.exists(): + modified = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(metadata_path.stat().st_mtime)) + log.info(f"Metadata : {metadata_path} ({modified})") + + for nim in sorted(system.list_identities()): + info = system.gallery.get_identity_info(nim) + if info: + log.info( + f" - {nim}: Q={info.quality_score:.2f}, " + f"photos={info.num_registrations}, " + f"enrolled={info.enrollment_date[:10] if info.enrollment_date else 'N/A'}" + ) + + +def main(): + parser = argparse.ArgumentParser(description="Test ModelBaru face recognition") + parser.add_argument("--image", "-i", type=str, help="single image path") + parser.add_argument("--expected", "-e", type=str, help="expected NIM for accuracy/verification") + parser.add_argument("--verify", "-v", type=str, help="alias for --expected") + parser.add_argument("--dataset", "-d", type=str, help="folder with NIM subfolders") + parser.add_argument("--limit", type=int, default=0, help="limit images per identity for dataset mode") + parser.add_argument("--camera", "-c", action="store_true", help="webcam mode") + parser.add_argument("--camera-index", type=int, default=0, help="webcam index") + parser.add_argument("--samples", type=int, default=0, help="stop webcam after N manual samples") + parser.add_argument("--threshold", type=float, help="temporary threshold for this test only") + parser.add_argument("--gallery", "-g", action="store_true", help="show enrolled gallery") + parser.add_argument("--verbose", action="store_true", help="show quality details") + parser.add_argument("--update-template", action="store_true", help="allow template update during test") + + args = parser.parse_args() + expected = args.expected or args.verify + + system = EnhancedFaceRecognitionSystem() + apply_test_threshold(system, args.threshold) + + if args.gallery: + show_gallery(system) + elif args.dataset: + evaluate_dataset(system, args.dataset, args.limit) + elif args.camera: + webcam_recognition( + system, + expected=expected, + camera_index=args.camera_index, + samples=args.samples, + update_template=args.update_template, + ) + elif args.image: + recognize_image( + system, + args.image, + expected=expected, + verbose=args.verbose, + update_template=args.update_template, + ) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/facerecognition_presensi/ModelBaru/reenroll_improved.py b/facerecognition_presensi/ModelBaru/reenroll_improved.py new file mode 100644 index 0000000..40eff79 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/reenroll_improved.py @@ -0,0 +1,139 @@ +""" +Re-Enrollment Script — Multi-Template Embedding +================================================ +Script untuk re-enroll semua mahasiswa dengan strategi embedding baru: +- CNN: Quality-Weighted Centroid + Multi-Template (max-similarity matching) +- LBPH: Multi-template dari semua foto + augmentasi dari top-2 +- L1-norm konsisten untuk LBPH (histogram intersection) + +Jalankan setelah perbaikan kode untuk menghasilkan gallery baru. + +Usage: + python reenroll_improved.py + python reenroll_improved.py --reset # Hapus gallery lama sebelum enroll + +Author: Skripsi 2026 +""" + +import os +import sys +import logging +from pathlib import Path + +# Setup path +_CURRENT_FILE = Path(__file__).resolve() +_MODELBARU_DIR = _CURRENT_FILE.parent +sys.path.insert(0, str(_MODELBARU_DIR)) + +os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2' +logging.basicConfig(level=logging.INFO, format='%(message)s') +log = logging.getLogger(__name__) + +from config import FACE_MAHASISWA_DIR, DATABASE_CONFIG +from enhanced_system import EnhancedFaceRecognitionSystem + +import cv2 + + +def reset_gallery(): + """Hapus semua file gallery lama.""" + gallery_files = [ + DATABASE_CONFIG['metadata'], + DATABASE_CONFIG['faiss_index'], + DATABASE_CONFIG['calibration'], + DATABASE_CONFIG['templates'], + _MODELBARU_DIR / "models" / "face_gallery.pkl", + _MODELBARU_DIR / "models" / "face_gallery_faiss", + ] + for path in gallery_files: + if path.exists() and path.is_file(): + os.remove(path) + log.info(f"[*] Deleted: {path.name}") + + +def reenroll_all(reset: bool = False): + """Re-enroll semua mahasiswa.""" + log.info("=" * 60) + log.info("RE-ENROLLMENT — Multi-Template Embedding") + log.info("=" * 60) + + if reset: + log.info("\n[*] Resetting gallery...") + reset_gallery() + + # Initialize system + log.info("\n[*] Initializing system...") + system = EnhancedFaceRecognitionSystem() + log.info(f"[*] Current gallery size: {system.get_gallery_size()}") + + # Find identities + identities = [] + for nim_dir in sorted(FACE_MAHASISWA_DIR.iterdir()): + if not nim_dir.is_dir(): + continue + images = sorted([ + str(f) for f in nim_dir.iterdir() + if f.suffix.lower() in {'.jpg', '.jpeg', '.png'} + ]) + if images: + identities.append((nim_dir.name, images)) + + log.info(f"\n[*] Found {len(identities)} identities to enroll") + + # Enroll each + success = 0 + failed = 0 + for nim, image_paths in identities: + log.info(f"\n{'─'*60}") + log.info(f"[*] Enrolling {nim} ({len(image_paths)} foto)") + + # Remove existing (if re-enrolling) + if nim in system.list_identities(): + system.remove_identity(nim) + + # Enroll + result = system.enroll_from_images(nim, image_paths) + if result: + success += 1 + log.info(f"[OK] {nim} enrolled successfully") + else: + failed += 1 + log.info(f"[FAIL] {nim} enrollment failed") + + # Summary + log.info(f"\n{'='*60}") + log.info(f"RE-ENROLLMENT COMPLETE") + log.info(f"{'='*60}") + log.info(f" Success: {success}/{len(identities)}") + log.info(f" Failed: {failed}/{len(identities)}") + log.info(f" Gallery: {system.get_gallery_size()} identities") + + # Gallery quality info + stats = system.get_gallery_info() + log.info(f"\n Gallery Quality Stats:") + log.info(f" Count: {stats.get('count', 0)}") + log.info(f" Avg Quality: {stats.get('avg_quality', 0):.2f}") + log.info(f" Min Quality: {stats.get('min_quality', 0):.2f}") + log.info(f" Max Quality: {stats.get('max_quality', 0):.2f}") + + # Per-identity detail + log.info(f"\n Per-Identity Detail:") + for nim in system.list_identities(): + info = system.gallery.get_identity_info(nim) + n_templates = len(system.gallery.cnn_templates.get(nim, [])) + n_lbph = len(system.gallery.lbph_gallery.get(nim, [])) + if info: + log.info(f" {nim}: Q={info.quality_score:.2f}, " + f"CNN templates={n_templates}, " + f"LBPH templates={n_lbph}, " + f"fotos={info.num_registrations}") + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="Re-enroll semua mahasiswa (Multi-Template)") + parser.add_argument("--reset", action="store_true", + help="Hapus gallery lama sebelum re-enroll") + args = parser.parse_args() + + reenroll_all(reset=args.reset) diff --git a/facerecognition_presensi/ModelBaru/resources/anti_spoof_models/2.7_80x80_MiniFASNetV2.pth b/facerecognition_presensi/ModelBaru/resources/anti_spoof_models/2.7_80x80_MiniFASNetV2.pth new file mode 100644 index 0000000..47c4af2 Binary files /dev/null and b/facerecognition_presensi/ModelBaru/resources/anti_spoof_models/2.7_80x80_MiniFASNetV2.pth differ diff --git a/facerecognition_presensi/ModelBaru/resources/anti_spoof_models/4_0_0_80x80_MiniFASNetV1SE.pth b/facerecognition_presensi/ModelBaru/resources/anti_spoof_models/4_0_0_80x80_MiniFASNetV1SE.pth new file mode 100644 index 0000000..55a25b3 Binary files /dev/null and b/facerecognition_presensi/ModelBaru/resources/anti_spoof_models/4_0_0_80x80_MiniFASNetV1SE.pth differ diff --git a/facerecognition_presensi/ModelBaru/resources/anti_spoof_models/README.md b/facerecognition_presensi/ModelBaru/resources/anti_spoof_models/README.md new file mode 100644 index 0000000..bafedd9 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/resources/anti_spoof_models/README.md @@ -0,0 +1,25 @@ +# Anti-Spoof Models + +Letakkan file .pth MiniFASNet di folder ini. + +## File yang dibutuhkan + +Download dari: https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/releases + +| File | Deskripsi | +|------|-----------| +| 2.7_80x80_MiniFASNetV2.pth | Model utama (ringan, ~1.6MB) — digunakan default | +| 4_0_0_80x80_MiniFASNetV1SE.pth | Model alternatif dengan SE attention | + +## Setup + +1. Clone SilentFaceAntiSpoofing di root proyek: + git clone https://github.com/minivision-ai/Silent-Face-Anti-Spoofing SilentFaceAntiSpoofing + +2. Download model files dan letakkan di folder ini (ModelBaru/resources/anti_spoof_models/) + +3. Set 'enabled': True di ANTI_SPOOF_CONFIG dalam config.py + +4. Gunakan AntiSpoofGuardedSystem sebagai pengganti EnhancedFaceRecognitionSystem: + from anti_spoof_manager import AntiSpoofManager, AntiSpoofGuardedSystem + system = AntiSpoofGuardedSystem.create() diff --git a/facerecognition_presensi/ModelBaru/score_fusion.py b/facerecognition_presensi/ModelBaru/score_fusion.py new file mode 100644 index 0000000..f0a93c4 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/score_fusion.py @@ -0,0 +1,446 @@ +""" +Score-Level Fusion Module (Revisi) +================================ +Quality-Aware Score-Level Fusion + +INI ADALAH NOVELTY UTAMA. + +Arsitektur: +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SCORE-LEVEL FUSION (BUKAN Feature-Level) │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Feature-Level Fusion (LAMA): + LBPH ──► Feature (640-D) ──┐ + ├─► Concat ──► Embedding + CNN ──► Feature (512-D) ──┘ + + ❌ Perlu Dense Layer yang tidak dilatih + ❌ Sulit dijelaskan ke penguji + ❌ Bukan zero retraining + +Score-Level Fusion (BARU): + ┌──────────────────────────────────────────────────────────────────────┐ + │ │ + │ Query Face │ + │ │ │ + │ ├──► LBPH ──► Normalize ──► Query LBPH (640-D) │ + │ │ │ │ + │ │ └──────┐ │ + │ │ │ │ + │ ├──► CNN ──► Normalize ──► Query CNN (512-D) │ + │ │ │ │ + │ │ │ │ + │ │ ┌──────────┴───────────┐ │ + │ │ │ │ │ + │ ▼ ▼ ▼ │ + │ ┌─────────────────────────────────────────────────────────────┐ │ + │ │ │ │ + │ │ GALLERY MATCHING (Parallel) │ │ + │ │ │ │ + │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ + │ │ │ LBPH Gallery │ │ CNN Gallery │ │ │ + │ │ │ (templates) │ │ (FAISS) │ │ │ + │ │ └────────┬────────┘ └────────┬────────┘ │ │ + │ │ │ │ │ │ + │ │ │ Cosine Sim │ Cosine Sim │ │ + │ │ │ │ │ │ + │ │ ▼ ▼ │ │ + │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ + │ │ │ LBPH Similarity │ │ CNN Similarity │ │ │ + │ │ │ = 0.81 │ │ = 0.94 │ │ │ + │ │ └─────────────────┘ └─────────────────┘ │ │ + │ │ │ │ │ │ + │ │ └──────────────┬───────────┘ │ │ + │ │ │ │ │ + │ │ ▼ │ │ + │ │ ┌─────────────────────────────────────────────────────┐ │ │ + │ │ │ │ │ │ + │ │ │ QUALITY-AWARE WEIGHT CALCULATOR │ │ │ + │ │ │ │ │ │ + │ │ │ Quality Score = 0.85 │ │ │ + │ │ │ │ │ │ + │ │ │ wCNN = QualityScore = 0.85 │ │ │ + │ │ │ wLBPH = 1 - QualityScore = 0.15 │ │ │ + │ │ │ │ │ │ + │ │ └───────────────────────┬───────────────────────────────┘ │ │ + │ │ │ │ │ + │ │ ▼ │ │ + │ │ ┌───────────────────────────────────────────────────────┐ │ │ + │ │ │ │ │ │ + │ │ │ FINAL SCORE = wCNN × CNN_sim + wLBPH × LBPH_sim │ │ │ + │ │ │ = 0.85 × 0.94 + 0.15 × 0.81 │ │ │ + │ │ │ = 0.799 + 0.1215 │ │ │ + │ │ │ = 0.92 │ │ │ + │ │ │ │ │ │ + │ │ └───────────────────────┬───────────────────────────────┘ │ │ + │ │ │ │ │ + │ └─────────────────────────────┼─────────────────────────────────┘ │ + │ │ │ + │ ▼ │ + │ ┌─────────────────────┐ │ + │ │ Threshold Check │ │ + │ │ (ROC + EER) │ │ + │ └─────────────────────┘ │ + │ │ + └─────────────────────────────────────────────────────────────────────────┘ + +Keuntungan Score-Level Fusion: +1. ✓ Zero retraining (tidak ada Dense Layer) +2. ✓ Mudah dijelaskan ke penguji +3. ✓ Sering dipakai di sistem biometrik +4. ✓ Sesuai konsep hybrid system +5. ✓ Quality digunakan secara eksplisit + +Author: Skripsi 2026 +""" + +import numpy as np +from typing import Dict, List, Tuple +import logging + +from config import FUSION_CONFIG, get_quality_weights + +log = logging.getLogger(__name__) + + +class ScoreLevelFusion: + """ + Score-Level Fusion - Quality-Aware. + + Novelty: Quality Score menentukan bobot fusion secara langsung. + + wCNN = QualityScore + wLBPH = 1 - QualityScore + + Contoh: + - Quality = 0.85 (bagus) → wCNN=0.85, wLBPH=0.15 (percaya CNN lebih) + - Quality = 0.40 (buruk) → wCNN=0.40, wLBPH=0.60 (percaya LBPH lebih karena CNN tidak reliable) + """ + + def __init__(self): + self.config = FUSION_CONFIG + + def _l2_normalize(self, vector: np.ndarray) -> np.ndarray: + """L2 normalize vector.""" + norm = np.linalg.norm(vector) + if norm < 1e-7: + return vector + return vector / norm + + def normalize_lbph(self, lbph_features: np.ndarray) -> np.ndarray: + """Normalize LBPH features.""" + return self._l2_normalize(lbph_features) + + def normalize_cnn(self, cnn_features: np.ndarray) -> np.ndarray: + """Normalize CNN features.""" + return self._l2_normalize(cnn_features) + + def compute_lbph_similarity( + self, + query_lbph: np.ndarray, + gallery_lbph: np.ndarray + ) -> float: + """ + Compute LBPH similarity (cosine similarity). + + Args: + query_lbph: Query LBPH features (normalized) + gallery_lbph: Gallery LBPH features (normalized) + + Returns: + Similarity score [0, 1] + """ + return float(np.dot(query_lbph, gallery_lbph)) + + def compute_cnn_similarity( + self, + query_cnn: np.ndarray, + gallery_cnn: np.ndarray + ) -> float: + """ + Compute CNN similarity (cosine similarity). + + Args: + query_cnn: Query CNN features (normalized) + gallery_cnn: Gallery CNN features (normalized) + + Returns: + Similarity score [0, 1] + """ + return float(np.dot(query_cnn, gallery_cnn)) + + def compute_weights(self, quality_score: float) -> Dict[str, float]: + """ + Compute fusion weights from quality score. + + Formula (revisi): + wCNN = clip(0.65 + 0.15(Q - 0.5), 0.5, 0.8) + wLBPH = 1 - wCNN + + Range: + - CNN: 50% - 80% + - LBPH: 20% - 50% + + Keuntungan: + - LBPH selalu punya kontribusi (20-50%) + - CNN lebih dominan saat kualitas bagus + - Tidak ada komponen yang di-nol-kan + + Args: + quality_score: Q ∈ [0, 1] + + Returns: + Dictionary dengan w_lbph dan w_cnn + """ + return get_quality_weights(quality_score) + + def fuse( + self, + lbph_similarity: float, + cnn_similarity: float, + quality_score: float + ) -> float: + """ + Fuse similarity scores using quality-aware weights. + + Final = wCNN × CNN_sim + wLBPH × LBPH_sim + + Args: + lbph_similarity: LBPH cosine similarity [0, 1] + cnn_similarity: CNN cosine similarity [0, 1] + quality_score: Face quality score [0, 1] + + Returns: + Final fused score [0, 1] + """ + weights = self.compute_weights(quality_score) + + final_score = ( + weights['mobilefacenet'] * cnn_similarity + + weights['lbph'] * lbph_similarity + ) + + return float(final_score) + + def fuse_batch( + self, + lbph_similarities: np.ndarray, + cnn_similarities: np.ndarray, + quality_score: float + ) -> np.ndarray: + """ + Fuse batch of similarity scores. + + Args: + lbph_similarities: Array of LBPH similarities + cnn_similarities: Array of CNN similarities + quality_score: Quality score (same for all) + + Returns: + Array of fused scores + """ + weights = self.compute_weights(quality_score) + + fused = ( + weights['mobilefacenet'] * cnn_similarities + + weights['lbph'] * lbph_similarities + ) + + return fused + + +class ScoreLevelGalleryMatcher: + """ + Gallery Matcher dengan Score-Level Fusion. + + Mengelola dua gallery: + 1. LBPH Gallery - untuk matching berbasis tekstur + 2. CNN Gallery (FAISS) - untuk matching berbasis deep features + """ + + def __init__(self, gallery): + """ + Args: + gallery: FAISSGallery instance yang sudah punya CNN embeddings + """ + self.gallery = gallery + self.fusion = ScoreLevelFusion() + + # LBPH gallery (dictionary untuk brute force matching) + self.lbph_gallery = {} # nim -> list of normalized LBPH templates + + def register_lbph(self, nim: str, lbph_embedding: np.ndarray): + """Register LBPH embedding for identity.""" + if nim not in self.lbph_gallery: + self.lbph_gallery[nim] = [] + + # Normalize and store + normalized = self.fusion.normalize_lbph(lbph_embedding) + self.lbph_gallery[nim].append(normalized) + + def register_from_gallery(self): + """Register LBPH templates from main gallery (if available).""" + # Ini akan dipanggil setelah enrollment + # Main gallery akan memberikan LBPH templates + pass + + def match_lbph(self, query_lbph: np.ndarray) -> List[Tuple[str, float]]: + """ + Match query LBPH against LBPH gallery. + + Args: + query_lbph: Query LBPH features + + Returns: + list of (nim, similarity) sorted by similarity + """ + query_norm = self.fusion.normalize_lbph(query_lbph) + + results = [] + for nim, templates in self.lbph_gallery.items(): + # Average similarity dengan semua templates + sims = [] + for tmpl in templates: + sim = self.fusion.compute_lbph_similarity(query_norm, tmpl) + sims.append(sim) + + avg_sim = np.mean(sims) + results.append((nim, avg_sim)) + + # Sort by similarity descending + results.sort(key=lambda x: x[1], reverse=True) + return results + + def match_both( + self, + query_lbph: np.ndarray, + query_cnn: np.ndarray, + quality_score: float, + top_k: int = 3 + ) -> List[Dict]: + """ + Match using BOTH LBPH and CNN with score-level fusion. + + Args: + query_lbph: Query LBPH features + query_cnn: Query CNN features + quality_score: Face quality score + top_k: Number of top candidates to return + + Returns: + List of dicts with nim, lbph_sim, cnn_sim, fused_score + """ + # Match CNN using FAISS + cnn_results = self.gallery.search_top_k(query_cnn, k=top_k * 2) + + # Match LBPH + lbph_results = self.match_lbph(query_lbph) + lbph_dict = {nim: sim for nim, sim in lbph_results} + + # Fusable candidates (yang ada di kedua gallery) + cnn_nims = [nim for nim, _ in cnn_results] + available_nims = [nim for nim in cnn_nims if nim in lbph_dict] + + if not available_nims: + # Fallback: hanya CNN + return [{'nim': nim, 'lbph_sim': 0, 'cnn_sim': sim, 'fused': sim} + for nim, sim in cnn_results[:top_k]] + + # Compute fused scores + fused_results = [] + for nim in available_nims[:top_k]: + cnn_sim = next(sim for n, sim in cnn_results if n == nim) + lbph_sim = lbph_dict[nim] + + fused = self.fusion.fuse(lbph_sim, cnn_sim, quality_score) + + fused_results.append({ + 'nim': nim, + 'lbph_sim': lbph_sim, + 'cnn_sim': cnn_sim, + 'fused': fused, + 'weights': self.fusion.compute_weights(quality_score) + }) + + # Sort by fused score + fused_results.sort(key=lambda x: x['fused'], reverse=True) + + return fused_results[:top_k] + + +# Convenience functions +def fuse_scores( + lbph_similarity: float, + cnn_similarity: float, + quality_score: float +) -> float: + """ + Convenience function untuk score-level fusion. + + Rumus: + Final = wCNN × CNN_sim + wLBPH × LBPH_sim + dimana wCNN = QualityScore, wLBPH = 1 - QualityScore + + Contoh: + - lbph_sim = 0.81, cnn_sim = 0.94, quality = 0.85 + - wCNN = 0.85, wLBPH = 0.15 + - Final = 0.85 × 0.94 + 0.15 × 0.81 = 0.92 + """ + fusion = ScoreLevelFusion() + return fusion.fuse(lbph_similarity, cnn_similarity, quality_score) + + +def compute_quality_weights(quality_score: float) -> Dict[str, float]: + """ + Convenience function untuk compute weights dari quality score. + + Returns: + {'lbph': float, 'mobilefacenet': float} + """ + return get_quality_weights(quality_score) + + +# Test function +if __name__ == "__main__": + log.info("Testing Score-Level Fusion (Revisi Weight Formula)...") + + fusion = ScoreLevelFusion() + + # Test case + lbph_sim = 0.81 + cnn_sim = 0.94 + quality = 0.85 + + weights = fusion.compute_weights(quality) + final = fusion.fuse(lbph_sim, cnn_sim, quality) + + log.info(f"\nScore-Level Fusion Example:") + log.info(f" LBPH Similarity: {lbph_sim}") + log.info(f" CNN Similarity: {cnn_sim}") + log.info(f" Quality Score: {quality}") + log.info(f"") + log.info(f" Weight Formula: wCNN = clip(0.65 + 0.15(Q-0.5), 0.5, 0.8)") + log.info(f"") + log.info(f" Weights:") + log.info(f" wCNN = {weights['mobilefacenet']:.3f}") + log.info(f" wLBPH = {weights['lbph']:.3f}") + log.info(f"") + log.info(f" Final Score:") + log.info(f" = {weights['mobilefacenet']:.3f} × {cnn_sim} + {weights['lbph']:.3f} × {lbph_sim}") + log.info(f" = {weights['mobilefacenet'] * cnn_sim:.3f} + {weights['lbph'] * lbph_sim:.3f}") + log.info(f" = {final:.3f}") + + # Test with different quality scores + log.info(f"\nWeight Analysis (Q ∈ [0,1]):") + log.info(f"{'Q':>5} | {'wCNN':>6} | {'wLBPH':>6} | Final Score") + log.info("-" * 40) + for q in [0.0, 0.2, 0.4, 0.5, 0.6, 0.8, 1.0]: + w = fusion.compute_weights(q) + f = fusion.fuse(lbph_sim, cnn_sim, q) + log.info(f"{q:>5.1f} | {w['mobilefacenet']:>6.3f} | {w['lbph']:>6.3f} | {f:.3f}") + + log.info(f"\nKesimpulan:") + log.info(f" - CNN: {fusion.config['weight']['min_cnn']:.0%} - {fusion.config['weight']['max_cnn']:.0%}") + log.info(f" - LBPH: {fusion.config['weight']['min_lbph']:.0%} - {fusion.config['weight']['max_lbph']:.0%}") + log.info(f" - LBPH selalu berkontribusi minimal {fusion.config['weight']['min_lbph']:.0%}") diff --git a/facerecognition_presensi/ModelBaru/threshold_calibrator.py b/facerecognition_presensi/ModelBaru/threshold_calibrator.py new file mode 100644 index 0000000..32b3931 --- /dev/null +++ b/facerecognition_presensi/ModelBaru/threshold_calibrator.py @@ -0,0 +1,448 @@ +""" +Threshold Calibration Module (Revisi) +================================== +Dynamic threshold calibration using ROC Curve and EER. + +Revisi: Hapus dynamic adjustment +- Quality sudah dipakai di Score-Level Fusion +- Tidak perlu dipakai lagi di threshold +- Threshold = ROC + EER saja + +Author: Skripsi 2026 +""" + +import numpy as np +from typing import List, Tuple, Optional +import json +import logging +from dataclasses import dataclass + +from config import THRESHOLD_CONFIG, DATABASE_CONFIG + +log = logging.getLogger(__name__) + + +@dataclass +class CalibrationResult: + """Results from threshold calibration.""" + optimal_threshold: float + eer_threshold: float + eer: float + far_at_optimal: float + frr_at_optimal: float + roc_points: int + calibration_date: str + + +class ThresholdCalibrator: + """ + Threshold calibrator using ROC Curve and EER. + + Metode: + 1. Hitung ROC Curve dari genuine dan impostor scores + 2. Cari EER point (dimana FAR = FRR) + 3. Threshold = EER threshold + + Revisi: Tidak ada dynamic adjustment + - Quality sudah dipakai di fusion + - Threshold tetap untuk semua kualitas wajah + """ + + def __init__(self): + self.config = THRESHOLD_CONFIG + self.calibration: Optional[CalibrationResult] = None + self._load_calibration() + + def _load_calibration(self): + """Load calibration from file.""" + calib_path = DATABASE_CONFIG['calibration'] + if calib_path.exists(): + try: + with open(calib_path, 'r') as f: + data = json.load(f) + self.calibration = CalibrationResult(**data) + log.info(f"[Calibrator] Loaded: threshold={self.calibration.optimal_threshold:.4f}") + except Exception as e: + log.warning(f"[Calibrator] Load failed: {e}") + + def _save_calibration(self): + """Save calibration to file.""" + if self.calibration is None: + return + + calib_path = DATABASE_CONFIG['calibration'] + calib_path.parent.mkdir(parents=True, exist_ok=True) + + data = { + 'optimal_threshold': self.calibration.optimal_threshold, + 'eer_threshold': self.calibration.eer_threshold, + 'eer': self.calibration.eer, + 'far_at_optimal': self.calibration.far_at_optimal, + 'frr_at_optimal': self.calibration.frr_at_optimal, + 'roc_points': self.calibration.roc_points, + 'calibration_date': self.calibration.calibration_date + } + + with open(calib_path, 'w') as f: + json.dump(data, f, indent=2) + + log.info(f"[Calibrator] Saved to {calib_path}") + + def compute_roc_curve( + self, + genuine_scores: np.ndarray, + impostor_scores: np.ndarray, + n_points: int = 100 + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Compute ROC curve. + + Args: + genuine_scores: Scores for genuine matches (same identity) + impostor_scores: Scores for impostor matches (different identity) + n_points: Number of threshold points + + Returns: + (thresholds, far, frr) + """ + min_score = min(genuine_scores.min(), impostor_scores.min()) + max_score = max(genuine_scores.max(), impostor_scores.max()) + thresholds = np.linspace(max_score, min_score, n_points) + + far = [] + frr = [] + + for thresh in thresholds: + # FAR = impostors accepted / total impostors + n_false_accept = np.sum(impostor_scores >= thresh) + far_rate = n_false_accept / len(impostor_scores) if len(impostor_scores) > 0 else 0 + + # FRR = genuine rejected / total genuine + n_false_reject = np.sum(genuine_scores < thresh) + frr_rate = n_false_reject / len(genuine_scores) if len(genuine_scores) > 0 else 0 + + far.append(far_rate) + frr.append(frr_rate) + + return thresholds, np.array(far), np.array(frr) + + def compute_eer( + self, + genuine_scores: np.ndarray, + impostor_scores: np.ndarray, + n_points: int = 100 + ) -> Tuple[float, float]: + """ + Compute Equal Error Rate (EER). + + EER adalah titik dimana FAR = FRR. + + Returns: + (eer, eer_threshold) + """ + thresholds, far, frr = self.compute_roc_curve( + genuine_scores, impostor_scores, n_points + ) + + diff = np.abs(far - frr) + eer_idx = np.argmin(diff) + eer = (far[eer_idx] + frr[eer_idx]) / 2 + eer_threshold = thresholds[eer_idx] + + return float(eer), float(eer_threshold) + + def find_optimal_threshold( + self, + genuine_scores: np.ndarray, + impostor_scores: np.ndarray + ) -> Tuple[float, float, float, float]: + """ + Find optimal threshold using EER method. + + Returns: + (threshold, far, frr, eer) + """ + eer, threshold = self.compute_eer(genuine_scores, impostor_scores) + + # Find FAR/FRR at this threshold + thresholds, far, frr = self.compute_roc_curve(genuine_scores, impostor_scores) + idx = np.argmin(np.abs(thresholds - threshold)) + + return threshold, far[idx], frr[idx], eer + + def calibrate( + self, + genuine_scores: List[float], + impostor_scores: List[float] + ) -> CalibrationResult: + """ + Calibrate threshold using validation data. + + Args: + genuine_scores: List of similarity scores for genuine matches + impostor_scores: List of similarity scores for impostor matches + + Returns: + CalibrationResult + """ + genuine = np.array(genuine_scores) + impostor = np.array(impostor_scores) + + if len(genuine) < 10 or len(impostor) < 10: + log.warning(f"[Calibrator] Insufficient data: {len(genuine)} genuine, {len(impostor)} impostor") + self.calibration = CalibrationResult( + optimal_threshold=self.config['initial_threshold'], + eer_threshold=self.config['initial_threshold'], + eer=0.0, + far_at_optimal=0.0, + frr_at_optimal=0.0, + roc_points=0, + calibration_date="" + ) + return self.calibration + + log.info(f"[Calibrator] Calibrating with {len(genuine)} genuine, {len(impostor)} impostor...") + + threshold, far, frr, eer = self.find_optimal_threshold(genuine, impostor) + + # Clamp the threshold to the operating range supported by the + # score-level matcher, then recompute FAR/FRR at the clamped value. + min_th = 0.35 + max_th = 0.65 + threshold = np.clip(threshold, min_th, max_th) + + eer, eer_thresh = self.compute_eer(genuine, impostor) + eer_thresh = np.clip(eer_thresh, min_th, max_th) + + far_at_threshold = float(np.mean(impostor >= threshold)) + frr_at_threshold = float(np.mean(genuine < threshold)) + + self.calibration = CalibrationResult( + optimal_threshold=float(threshold), + eer_threshold=float(eer_thresh), + eer=float(eer), + far_at_optimal=far_at_threshold, + frr_at_optimal=frr_at_threshold, + roc_points=len(genuine) + len(impostor), + calibration_date=str(np.datetime64('now')) + ) + + self._save_calibration() + + log.info(f"[Calibrator] Result:") + log.info(f" Threshold: {threshold:.4f}") + log.info(f" EER: {eer:.4f} ({eer*100:.2f}%)") + + return self.calibration + + def get_threshold(self) -> float: + """ + Get recognition threshold. + + Revisi: Tidak ada dynamic adjustment + - Quality sudah dipakai di Score-Level Fusion + - Threshold tetap untuk semua kualitas wajah + + Returns: + Threshold value + """ + if self.calibration is None: + return self.config['initial_threshold'] + return self.calibration.optimal_threshold + + def get_threshold_description(self) -> str: + """ + Get scientific description for thesis/skripsi. + + Returns: + Description string + """ + if self.calibration is None: + return ( + f"Threshold ditetapkan pada nilai awal {self.config['initial_threshold']:.2f}. " + f"Rekomendasi: lakukan kalibrasi dengan data validasi menggunakan " + f"ROC Curve dan Equal Error Rate (EER)." + ) + + return ( + f"Threshold diperoleh melalui proses kalibrasi menggunakan ROC Curve " + f"dan Equal Error Rate (EER) pada data validasi ({self.calibration.roc_points} pasangan). " + f"Threshold optimal: {self.calibration.optimal_threshold:.4f}, " + f"EER: {self.calibration.eer:.4f} ({self.calibration.eer*100:.2f}%), " + f"FAR: {self.calibration.far_at_optimal:.4f}, " + f"FRR: {self.calibration.frr_at_optimal:.4f}." + ) + + def calibrate_from_gallery( + self, + gallery, + _augmentation=None, + _lbph_extractor=None, + _mobile_extractor=None + ) -> Optional[CalibrationResult]: + """ + Auto-calibrate threshold from gallery using genuine and impostor scores. + + Revisi Akurasi v3: Automatic calibration from enrolled identities. + + Genuine scores: similarity between template vs augmented versions + from photos of the SAME enrolled identity. + + Impostor scores: similarity between templates of DIFFERENT identities + in the gallery. + + Args: + gallery: FAISSGallery instance with enrolled identities + _augmentation: Reserved for future use (generate genuine scores from original photos) + _lbph_extractor: Reserved for future use + _mobile_extractor: Reserved for future use + + Returns: + CalibrationResult or None if insufficient data + """ + if gallery.get_identity_count() < 2: + log.warning("[Calibrator] Need at least 2 identities for auto-calibration") + return None + + log.info("[Calibrator] Starting auto-calibration from gallery...") + + genuine_scores = [] + impostor_scores = [] + + identity_list = gallery.list_identities() + + # Generate genuine and impostor scores + for nim in identity_list: + # Get identity info to find enrollment photos (stored in metadata) + meta = gallery.get_identity_info(nim) + if meta is None: + continue + + # Get stored templates + cnn_template = gallery.identities.get(nim) + lbph_templates = gallery.lbph_gallery.get(nim, []) + + if cnn_template is None or len(lbph_templates) == 0: + continue + + # Generate augmented versions to compute genuine scores + # Note: In real scenario, we'd use the original enrollment photos + # For auto-calibration, we use stored LBPH templates to compute impostor scores + + # IMPOSTOR SCORES: CNN similarity between different identities + for other_nim in identity_list: + if other_nim == nim: + continue + other_template = gallery.identities.get(other_nim) + if other_template is not None: + # CNN cosine similarity (dot product) + cnn_sim = float(np.dot(cnn_template, other_template)) + impostor_scores.append(cnn_sim) + + # IMPOSTOR SCORES: LBPH histogram intersection between different identities + other_lbph = gallery.lbph_gallery.get(other_nim, []) + if len(other_lbph) > 0: + # Use first LBPH template for impostor comparison + other_lbph_template = other_lbph[0] + for lbph_t in lbph_templates: + # L1-normalize both + lbph_t_norm = lbph_t / (lbph_t.sum() + 1e-7) + other_norm = other_lbph_template / (other_lbph_template.sum() + 1e-7) + # Histogram intersection + lbph_sim = float(np.sum(np.minimum(lbph_t_norm, other_norm))) + impostor_scores.append(lbph_sim) + + # GENUINE SCORES: We need original enrollment photos + # For auto-calibration without original photos, we estimate genuine scores + # based on template consistency (within-identity similarity) + # This is an approximation - ideally we'd have original photos + + # For now, estimate genuine scores as high similarity within same identity + # Using stored LBPH templates (they should be similar if from same person) + if len(lbph_templates) > 1: + for i in range(len(lbph_templates)): + for j in range(i + 1, len(lbph_templates)): + t1 = lbph_templates[i] + t2 = lbph_templates[j] + # L1-normalize + t1_norm = t1 / (t1.sum() + 1e-7) + t2_norm = t2 / (t2.sum() + 1e-7) + # Histogram intersection (within identity should be high) + lbph_sim = float(np.sum(np.minimum(t1_norm, t2_norm))) + genuine_scores.append(lbph_sim) + + # Check if we have enough data + min_samples = 10 + if len(genuine_scores) < min_samples or len(impostor_scores) < min_samples: + log.warning(f"[Calibrator] Insufficient calibration data: " + f"genuine={len(genuine_scores)}, impostor={len(impostor_scores)}") + return None + + # Compute final scores (fused for comparison) + # For simplicity, we use CNN scores for threshold calibration + # since CNN is dominant (80-95%) + final_genuine = np.array(genuine_scores) + final_impostor = np.array(impostor_scores) + + # Filter outliers (keep 99th percentile) + if len(final_genuine) > 10: + threshold_99 = np.percentile(final_genuine, 99) + final_genuine = final_genuine[final_genuine <= threshold_99] + + if len(final_impostor) > 10: + threshold_1 = np.percentile(final_impostor, 1) + final_impostor = final_impostor[final_impostor >= threshold_1] + + log.info(f"[Calibrator] Using {len(final_genuine)} genuine, " + f"{len(final_impostor)} impostor scores") + + # Calibrate using the collected scores + return self.calibrate(final_genuine.tolist(), final_impostor.tolist()) + + +def generate_synthetic_validation_data( + n_identities: int = 50, + n_genuine_per_id: int = 5, + n_impostor_per_id: int = 20 +) -> Tuple[List[float], List[float]]: + """Generate synthetic validation data.""" + genuine_scores = [] + impostor_scores = [] + + for _ in range(n_identities * n_genuine_per_id): + score = np.random.normal(0.75, 0.1) + score = np.clip(score, 0.5, 1.0) + genuine_scores.append(score) + + for _ in range(n_identities * n_impostor_per_id): + score = np.random.normal(0.25, 0.1) + score = np.clip(score, 0.0, 0.5) + impostor_scores.append(score) + + return genuine_scores, impostor_scores + + +# Test function +if __name__ == "__main__": + log.info("Testing ThresholdCalibrator (Revisi)...") + + calibrator = ThresholdCalibrator() + + # Generate synthetic data + genuine, impostor = generate_synthetic_validation_data( + n_identities=100, + n_genuine_per_id=10, + n_impostor_per_id=50 + ) + + log.info(f"Generated {len(genuine)} genuine, {len(impostor)} impostor scores") + + # Calibrate + result = calibrator.calibrate(genuine, impostor) + + # Description + description = calibrator.get_threshold_description() + log.info(f"\nDescription:\n{description}") + + # Test (no dynamic adjustment anymore) + log.info(f"\nThreshold: {calibrator.get_threshold():.4f}") diff --git a/facerecognition_presensi/README.md b/facerecognition_presensi/README.md new file mode 100644 index 0000000..7a6fc4c --- /dev/null +++ b/facerecognition_presensi/README.md @@ -0,0 +1,116 @@ +# Sistem Presensi Pengenalan Wajah + +Backend FastAPI + MySQL, website React/Vite, dan aplikasi Android Expo. Pipeline aktif adalah `enhanced` (YuNet, MobileFaceNet, LBPH, FAISS) di `ModelBaru/`. Kode dan model dasar dipertahankan; data mahasiswa, foto enrollment, template biometrik, dan konfigurasi lokal tidak boleh dipublikasikan. + +## 1. Kebutuhan + +- Python **3.10 atau 3.11** (TensorFlow 2.15 tidak mendukung semua versi Python). +- Node.js **20.19+ atau 22.12+**, npm; MySQL 8. +- Android Studio/JDK 17 untuk build Android native; perangkat fisik memerlukan akses LAN ke backend. + +## 2. Database dan konfigurasi backend + +Buat database kosong dan pengguna khusus melalui MySQL (ganti password contoh): + +```sql +CREATE DATABASE skripsi_presensi CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'presensi_app'@'localhost' IDENTIFIED BY 'GANTI_PASSWORD_DATABASE'; +GRANT ALL PRIVILEGES ON skripsi_presensi.* TO 'presensi_app'@'localhost'; +``` + +Dari PowerShell di root proyek: + +```powershell +cd backend +py -3.11 -m venv venv +.\venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +pip install -r requirements.txt +Copy-Item .env.example .env +python -c "import secrets; print(secrets.token_hex(32))" +``` + +Edit `backend/.env`: isi DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME dan salin hasil generator ke SECRET_KEY. Kunci minimal 32 karakter; buat kunci berbeda untuk setiap deployment. DEBUG=false. Jangan unggah `.env`. Nilai password dengan `#` atau spasi dapat diapit tanda kutip. URL database dibentuk otomatis dan mendukung karakter khusus pada password. + +FACE_MODEL_TYPE=enhanced adalah pipeline yang didukung paket ini. PRELOAD_ML_MODEL=false memuat ML saat pertama dipakai; ubah menjadi true bila ingin mencoba memuatnya saat startup. UPLOAD_DIR dan DATASET_DIR bersifat relatif terhadap folder backend: jalankan server dari folder tersebut. + +## 3. Membuat tabel dan admin + +Untuk **database baru/kosong**: + +```powershell +python init_db.py +python seed.py +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +`seed.py` meminta nama, email, dan password admin secara interaktif; tidak ada akun/password bawaan. API: http://localhost:8000/api/docs. Health: http://localhost:8000/api/health. + +`init_db.py` membuat 20 tabel melalui model SQLAlchemy: users, prodi, dosen, matakuliah, angkatan, golongan, geolokasi, mahasiswa, kelas, penawaran_matakuliah, penawaran_kelas, pengajar, jadwal, pertemuan, pengajar_pertemuan, dataset_wajah, registrasi_wajah, presensi, log_deteksi, history_presensi. Tidak perlu mengimpor dump SQL atau data mahasiswa asli. Startup backend juga membuat tabel yang belum ada. + +### Database lama + +Cadangkan database dahulu. `create_all` **tidak memperbarui kolom pada tabel lama**. Skrip berikut adalah patch historis, bukan rangkaian migrasi lengkap untuk semua versi: + +| Skrip | Kegunaan | +| --- | --- | +| migrate_kelas_mahasiswa.py | Menambah mahasiswa.id_kelas dan relasi kelas jika kolom belum ada | +| migrate_pertemuan.py | Menambah geolokasi dan pertemuan.id_geolokasi/mode_lokasi jika belum ada | +| migrate_fk_cascade.py | Mengubah tiga foreign key akademik menjadi ON DELETE CASCADE | +| migrate.py | **Menghapus semua tabel dan membuat ulang; jangan dipakai untuk instalasi normal** | + +Untuk repo bersih, gunakan database baru + init_db.py. Untuk upgrade database lama, cocokkan skema terhadap model sebelum menjalankan patch; patch tersebut tidak menjamin seluruh perubahan ant versi tercakup. + +## 4. Website + +Buka terminal baru dari root: + +```powershell +cd Website +npm ci +Copy-Item .env.example .env +npm run dev +``` + +`VITE_API_BASE_URL=http://localhost:8000/api/v1`; sesuaikan host jika backend berada di komputer lain. Ikuti alamat yang ditampilkan Vite. Build: `npm run build`. Semua nilai VITE_* terlihat oleh pengguna browser; jangan taruh rahasia di sini. + +## 5. Android + +```powershell +cd AndroidFolder +npm ci +Copy-Item .env.example .env +npm start +``` + +Isi EXPO_PUBLIC_API_URL tanpa `/api/v1`: emulator Android memakai `http://10.0.2.2:8000`; perangkat fisik memakai `http://IP_LAN_KOMPUTER:8000`. Restart Expo setelah mengubah `.env`. EXPO_PUBLIC_* bersifat publik. Untuk build native lokal, jalankan `npm run android` setelah Android Studio/JDK siap. Keystore debug hanya untuk pengembangan; build rilis harus memakai signing key pribadi di luar repositori. + +## 6. Model wajah dan penggunaan pertama + +Model dasar yang diperlukan dipertahankan di: + +- ModelBaru/models/face_detection_yunet_2023mar.onnx +- ModelBaru/models/mobilefacenet.onnx +- ModelBaru/resources/anti_spoof_models/*.pth +- SilentFaceAntiSpoofing/resources/ (model anti-spoof dan deteksi beserta kode serta lisensinya) + +Jangan menghapus bobot tersebut seperti menghapus foto dataset. Model dasar berbeda dari template/embedding identitas mahasiswa. Sebelum distribusi publik, pastikan hak distribusi bobot pihak ketiga sesuai sumbernya; asal dan lisensi MobileFaceNet lokal belum terverifikasi. + +Mulai dengan membuat data master (prodi, angkatan, golongan, kelas), dosen/mahasiswa dan jadwal lewat UI/API. Daftarkan foto wajah baru milik pengguna yang berwenang lewat fitur enrollment. Gallery awal kosong sehingga pengenalan belum dapat mengidentifikasi siapa pun sebelum enrollment. Template `face_metadata.pkl`, foto `face_mahasiswa/`, uploads dan datasets dibuat ulang saat penggunaan dan diabaikan Git. Hasil kalibrasi lama dibuang; kalibrasikan threshold pada data evaluasi sendiri bila diperlukan. + +## 7. Sebelum mengunggah ke GitHub + +`.gitignore` mengecualikan konfigurasi lokal, dataset, template biometrik, dependensi, dan hasil build. Jangan memakai `git add -f` untuk file-file tersebut. Folder ini belum memiliki repositori Git root yang valid; buat repositori baru setelah pembersihan: + +```powershell +git init +git add . +git status --short +git diff --cached --stat +``` + +Periksa daftar sebelum commit: tidak boleh ada `.env` selain `.env.example`, foto mahasiswa, dump database, token, private key, atau file template identitas. Lalu commit dan tambahkan remote repo GitHub milik Anda. Jangan mendorong riwayat repo lama yang pernah menyimpan rahasia. Jika rahasia pernah dipublikasikan, ganti rahasianya; menghapus file terbaru saja tidak menghapusnya dari riwayat. + +## Batas verifikasi + +Lihat CLEANUP_REPORT.md untuk hasil pemeriksaan aktual. Backend yang dapat diimpor atau halaman health yang sukses belum membuktikan koneksi MySQL, login, kamera, enrollment dan presensi berhasil. Uji alur lengkap dengan database dan perangkat Anda sebelum penggunaan nyata. diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/.gitignore b/facerecognition_presensi/SilentFaceAntiSpoofing/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/LICENSE b/facerecognition_presensi/SilentFaceAntiSpoofing/LICENSE new file mode 100644 index 0000000..233b067 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 Minivision + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/README.md b/facerecognition_presensi/SilentFaceAntiSpoofing/README.md new file mode 100644 index 0000000..4f5f96b --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/README.md @@ -0,0 +1,112 @@ +**中文版**|[English Version](README_EN.md) +![静默活体检测](https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/blob/master/images/logo.jpg) +# 静默活体检测 (Silent-Face-Anti-Spoofing) +该项目为[小视科技](https://www.minivision.cn/)的静默活体检测项目,您可以扫描下方的二维码获取安卓端APK,体验静默活体的检测效果. + +## 更新 +**2020-07-30:** 开源caffe模型,分享工业级静默活体检测算法技术解析直播视频以及相关文件。 +## 简介 +在本工程中我们开源了活体模型训练架构,数据预处理方法,模型训练和测试脚本以及开源的APK供大家测试使用。 + +活体检测技术主要是判别机器前出现的人脸是真实还是伪造的,其中借助其他媒介呈现的人脸都可以定义为虚假的人脸,包括打印的纸质照片、电子产品的显示屏幕、硅胶面具、立体的3D人像等。目前主流的活体解决方案分为配合式活体检测和非配合式活体检测(静默活体检测)。配合式活体检测需要用户根据提示完成指定的动作,然后再进行活体校验,静默活体则在用户无感的情况下直接进行活体校验。 + +因傅里叶频谱图一定程度上能够反应真假脸在频域的差异,因此我们采用了一种基于傅里叶频谱图辅助监督的静默活体检测方法, 模型架构由分类主分支和傅里叶频谱图辅助监督分支构成,整体架构如下图所示: +![整体架构图](https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/blob/master/images/framework.jpg) + +使用自研的模型剪枝方法,将MobileFaceNet的Flops从0.224G降低待了0.081G,在精度损失不大的情况下,明显提升模型的性能(降低计算量与参数量). + +|Model|FLOPs|Params| +| :------:|:-----:|:-----:| +|MobileFaceNet|0.224G|0.991M| +|MiniFASNetV1|0.081G|0.414M| +|MiniFASNetV2|0.081G|0.435M| + +## APK +### APK源码 +开源了适用于安卓平台的部署代码:https://github.com/minivision-ai/Silent-Face-Anti-Spoofing-APK + +### Demo + + +### 关键指标 +| Model(input 80x80)|FLOPs|Speed| FPR | TPR |备注 | +| :------:|:-----:|:-----:| :----: | :----: | :----: | +| APK模型 |84M| 20ms | 1e-5|97.8%| 开源| +| 高精度模型 |162M| 40ms| 1e-5 |99.7%| 未开源 | + +### 测试方法 +- 显示信息:速度(ms), 置信度(0~1)以及活体检测结果(真脸or假脸) +- 点击右上角图标可设置阈值,如果置信度大于阈值,为真脸,否则为假脸 + +### 测试须知 +- 所有测试图片必须通过摄像头采集得到,否则不符合正常场景使用规范,算法效果也无法保证。 +- 因为RGB静默活体对摄像头型号和使用场景鲁棒性受限,所以实际使用体验会有一定差异。 +- 测试时,应保证有完整的人脸出现在视图中,并且人脸旋转角与竖直方向小于30度(符合正常刷脸场景),否则影响体验。   + +**已测试型号** + +|型号|麒麟990 5G|麒麟990 |骁龙845 |麒麟810 |RK3288 | +| :------:|:-----:|:-----:|:-----:|:-----:|:-----:| +|速度/ms|19|23|24|25|90| + +## 工程 +### 安装依赖库 +``` +pip install -r requirements.txt +``` +### Clone +``` +git clone https://github.com/minivision-ai/Silent-Face-Anti-Spoofing +cd Silent-Face-Anti-Spoofing +``` +### 数据预处理 +1.将训练集分为3类,将相同类别的图片放入一个文件夹; +2.因采用多尺度模型融合的方法,分别用原图和不同的patch训练模型,所以将数据分为原图和基于原图的patch; +- 原图(org_1_height**x**width),直接将原图resize到固定尺寸(width, height),如图1所示; +- 基于原图的patch(scale_height**x**width),采用人脸检测器人脸,获取人脸框,按照一定比例(scale)对人脸框进行扩边,为了保证模型的输入尺寸的一致性,将人脸框区域resize到固定尺寸(width, height),图2-4分别显示了scale为1,2.7和4的patch样例; +![patch demo](https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/blob/master/images/patch_demo.png) + +3.采用傅里叶频谱图作为辅助监督,训练集图片在线生成对应的傅里叶频谱图. +**数据集的目录结构如下所示** +``` +├── datasets + └── RGB_Images + ├── org_1_80x60 + ├── 0 + ├── aaa.png + ├── bbb.png + └── ... + ├── 1 + ├── ddd.png + ├── eee.png + └── ... + └── 2 + ├── ggg.png + ├── hhh.png + └── ... + ├── 1_80x80 + └── ... +``` +### 训练 +``` +python train.py --device_ids 0 --patch_info your_patch +``` +### 测试 + ./resources/anti_spoof_models 活体检测的融合模型 + ./resources/detection_model 检测器模型 + ./images/sample 测试图片 + ``` + python test.py --image_name your_image_name + ``` +## 相关资源 +[百度网盘](https://pan.baidu.com/s/1u3BPHIEU4GmTti0G3LIDGQ)提取码:6d8q +(1)工业级静默活体检测开源算法技术解析[直播回放视频](https://www.bilibili.com/video/BV1qZ4y1T7CH); +(2)直播视频中的思维导图文件,存放在files目录下; +(3)开源模型的caffemodel,存放在models目录下; + +## 参考 +- 检测器 [RetinaFace](https://github.com/deepinsight/insightface/tree/master/RetinaFace) + +针对此项目,为了方便开发者们的技术交流,创建了QQ群:1121178835,欢迎加入。 + +除了本次开源的静默活体检测算法外,小视科技还拥有多项人脸识别、人体识别相关的自研算法及商用SDK。有兴趣的个人开发者或企业开发者可登录[小视科技Mini-AI开放平台](https://ai.minivision.cn/)了解和联系我们。 diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/README_EN.md b/facerecognition_presensi/SilentFaceAntiSpoofing/README_EN.md new file mode 100644 index 0000000..3c1334f --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/README_EN.md @@ -0,0 +1,112 @@ +[中文版](README.md)|**English Version** +![Silent-Face-Anti-Spoofing](https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/blob/master/images/logo.jpg) +# Silent-Face-Anti-Spoofing + +This project is Silent-Face-Anti-Spoofing belongs to [minivision technology](https://www.minivision.cn/). You can scan the QR code below to get APK and install it on Android side to experience the effect of real time living detection(silent face anti-spoofing detection). + + +## Introduction + +In this project, we open source the silent face anti-spoofing model with training architecture, data preprocessing method, model training & test script and open source APK for real time testing. + +The main purpose of silent face anti-spoofing detection technology is to judge whether the face in front of the machine is real or fake. The face presented by other media can be defined as false face, including printed paper photos, display screen of electronic products, silicone mask, 3D human image, etc. At present, the mainstream solutions includes cooperative living detection and non cooperative living detection (silent living detection). Cooperative living detection requires the user to complete the specified action according to the prompt, and then carry out the live verification, while the silent live detection directly performs the live verification. + +Since the Fourier spectrum can reflect the difference of true and false faces in frequency domain to a certain extent, we adopt a silent living detection method based on the auxiliary supervision of Fourier spectrum. The model architecture consists of the main classification branch and the auxiliary supervision branch of Fourier spectrum. The overall architecture is shown in the following figure: + +![overall architecture](https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/blob/master/images/framework.jpg) + +By using our self-developed model pruning method, the FLOPs of MobileFaceNet is reduced from 0.224G to 0.081G, and the performance of the model is significantly improved (the amount of calculation and parameters is reduced) with little loss of precision. + + +|Model|FLOPs|Params| +| :------:|:-----:|:-----:| +|MobileFaceNet|0.224G|0.991M| +|MiniFASNetV1|0.081G|0.414M| +|MiniFASNetV2|0.081G|0.435M| + +## APK +### APK source code +Open source for Android platform deployment code: https://github.com/minivision-ai/Silent-Face-Anti-Spoofing-APK + +### Demo + + +### Performance +| Model|FLOPs|Speed| FPR | TPR |comments | +| :------:|:-----:|:-----:| :----: | :----: | :----: | +| APK |84M| 20ms | 1e-5|97.8%| Open Source| +| High precision model |162M| 40ms| 1e-5 |99.7%| Private | + +### Test Method + +- Display information: speed(ms), confidence(0 ~ 1) and in living detection test results (true face or false face). +- Click the icon in the upper right corner to set the threshold value. If the confidence level is greater than the threshold value, it is a true face, otherwise it is a fake face. + +### Before test you must know + +- All the test images must be collected by camera, otherwise it does not conform to the normal scene usage specification, and the algorithm effect cannot be guaranteed. +- Because the robustness of RGB silent living detection depending on camera model and scene, the actual use experience could be different. +- During the test, it should be ensured that a complete face appears in the view, and the rotation angle and vertical direction of the face are less than 30 degrees (in line with the normal face recognition scene), otherwise, the experience will be affected.  + +**Tested mobile phone processor** + +|type|Kirin990 5G|Kirin990 |Qualcomm845 |Kirin810 |RK3288 | +| :------:|:-----:|:-----:|:-----:|:-----:|:-----:| +|Speed/ms|19|23|24|25|90| + +## Repo +### Install dependency Library +``` +pip install -r requirements.txt +``` +### Clone +``` +git clone https://github.com/minivision-ai/Silent-Face-Anti-Spoofing +cd Silent-Face-Anti-Spoofing +``` +### Data Preprocessing +1.The training set is divided into three categories, and the pictures of the same category are put into a folder; +2.Due to the multi-scale model fusion method, the original image and different patch are used to train the model, so the data is divided into the original map and the patch based on the Original picture; +- Original picture(org_1_height**x**width),resize the original image to a fixed size (width, height), as shown in Figure 1; +- Patch based on original(scale_height**x**width),The face detector is used to obtain the face frame, and the edge of the face frame is expanded according to a certain scale. In order to ensure the consistency of the input size of the model, the area of the face frame is resized to a fixed size (width, height). Fig. 2-4 shows the patch examples with scales of 1, 2.7 and 4; +![patch demo](https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/blob/master/images/patch_demo.png) + +3.The Fourier spectrum is used as the auxiliary supervision, and the corresponding Fourier spectrum is generated online from the training set images. +**The directory structure of the dataset is shown below** +``` +├── datasets + └── RGB_Images + ├── org_1_80x60 + ├── 0 + ├── aaa.png + ├── bbb.png + └── ... + ├── 1 + ├── ddd.png + ├── eee.png + └── ... + └── 2 + ├── ggg.png + ├── hhh.png + └── ... + ├── 1_80x80 + └── ... +``` +### Train +``` +python train.py --device_ids 0 --patch_info your_patch +``` +### Test + ./resources/anti_spoof_models Fusion model of in living detection + ./resources/detection_model Detector + ./images/sample Test Images + ``` + python test.py --image_name your_image_name + ``` +## Reference +- Detector [RetinaFace](https://github.com/deepinsight/insightface/tree/master/RetinaFace) + +For this project, in order to facilitate the technical exchange of developers, we created QQ group: 1121178835, welcome to join. + +In addition to the open-source silent living detection algorithm, Minivision technology also has a number of self-developed algorithms and SDK related to face recognition and human body recognition. Interested individual developers or enterprise developers can visit our website: [Mini-AI Open Platform](https://ai.minivision.cn/) +Welcome to contact us. diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/requirements.txt b/facerecognition_presensi/SilentFaceAntiSpoofing/requirements.txt new file mode 100644 index 0000000..6e1adf5 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/requirements.txt @@ -0,0 +1,8 @@ +easydict==1.9 +numpy==1.17.0 +tqdm==4.31.1 +torchvision==0.4.0 +torch==1.2.0 +opencv_python==4.2.0.34 +Pillow==7.1.2 +tensorboardX==2.0 diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/resources/anti_spoof_models/2.7_80x80_MiniFASNetV2.pth b/facerecognition_presensi/SilentFaceAntiSpoofing/resources/anti_spoof_models/2.7_80x80_MiniFASNetV2.pth new file mode 100644 index 0000000..47c4af2 Binary files /dev/null and b/facerecognition_presensi/SilentFaceAntiSpoofing/resources/anti_spoof_models/2.7_80x80_MiniFASNetV2.pth differ diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/resources/anti_spoof_models/4_0_0_80x80_MiniFASNetV1SE.pth b/facerecognition_presensi/SilentFaceAntiSpoofing/resources/anti_spoof_models/4_0_0_80x80_MiniFASNetV1SE.pth new file mode 100644 index 0000000..55a25b3 Binary files /dev/null and b/facerecognition_presensi/SilentFaceAntiSpoofing/resources/anti_spoof_models/4_0_0_80x80_MiniFASNetV1SE.pth differ diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/resources/detection_model/Widerface-RetinaFace.caffemodel b/facerecognition_presensi/SilentFaceAntiSpoofing/resources/detection_model/Widerface-RetinaFace.caffemodel new file mode 100644 index 0000000..8f7b517 Binary files /dev/null and b/facerecognition_presensi/SilentFaceAntiSpoofing/resources/detection_model/Widerface-RetinaFace.caffemodel differ diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/resources/detection_model/deploy.prototxt b/facerecognition_presensi/SilentFaceAntiSpoofing/resources/detection_model/deploy.prototxt new file mode 100644 index 0000000..6821aca --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/resources/detection_model/deploy.prototxt @@ -0,0 +1,2499 @@ +name: "20200403141819_Widerface-RetinaFace_mb_640_negscope-0_epoch_4" +input: "data" +input_dim: 1 +input_dim: 3 +input_dim: 640 +input_dim: 640 +layer { + name: "conv1" + type: "Convolution" + bottom: "data" + top: "conv_blob1" + convolution_param { + num_output: 8 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 2 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm1" + type: "BatchNorm" + bottom: "conv_blob1" + top: "batch_norm_blob1" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale1" + type: "Scale" + bottom: "batch_norm_blob1" + top: "batch_norm_blob1" + scale_param { + bias_term: true + } +} +layer { + name: "relu1" + type: "ReLU" + bottom: "batch_norm_blob1" + top: "relu_blob1" +} +layer { + name: "conv2" + type: "Convolution" + bottom: "relu_blob1" + top: "conv_blob2" + convolution_param { + num_output: 8 + bias_term: false + pad: 1 + kernel_size: 3 + group: 8 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm2" + type: "BatchNorm" + bottom: "conv_blob2" + top: "batch_norm_blob2" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale2" + type: "Scale" + bottom: "batch_norm_blob2" + top: "batch_norm_blob2" + scale_param { + bias_term: true + } +} +layer { + name: "relu2" + type: "ReLU" + bottom: "batch_norm_blob2" + top: "relu_blob2" +} +layer { + name: "conv3" + type: "Convolution" + bottom: "relu_blob2" + top: "conv_blob3" + convolution_param { + num_output: 16 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm3" + type: "BatchNorm" + bottom: "conv_blob3" + top: "batch_norm_blob3" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale3" + type: "Scale" + bottom: "batch_norm_blob3" + top: "batch_norm_blob3" + scale_param { + bias_term: true + } +} +layer { + name: "relu3" + type: "ReLU" + bottom: "batch_norm_blob3" + top: "relu_blob3" +} +layer { + name: "conv4" + type: "Convolution" + bottom: "relu_blob3" + top: "conv_blob4" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 16 + stride: 2 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm4" + type: "BatchNorm" + bottom: "conv_blob4" + top: "batch_norm_blob4" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale4" + type: "Scale" + bottom: "batch_norm_blob4" + top: "batch_norm_blob4" + scale_param { + bias_term: true + } +} +layer { + name: "relu4" + type: "ReLU" + bottom: "batch_norm_blob4" + top: "relu_blob4" +} +layer { + name: "conv5" + type: "Convolution" + bottom: "relu_blob4" + top: "conv_blob5" + convolution_param { + num_output: 32 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm5" + type: "BatchNorm" + bottom: "conv_blob5" + top: "batch_norm_blob5" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale5" + type: "Scale" + bottom: "batch_norm_blob5" + top: "batch_norm_blob5" + scale_param { + bias_term: true + } +} +layer { + name: "relu5" + type: "ReLU" + bottom: "batch_norm_blob5" + top: "relu_blob5" +} +layer { + name: "conv6" + type: "Convolution" + bottom: "relu_blob5" + top: "conv_blob6" + convolution_param { + num_output: 32 + bias_term: false + pad: 1 + kernel_size: 3 + group: 32 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm6" + type: "BatchNorm" + bottom: "conv_blob6" + top: "batch_norm_blob6" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale6" + type: "Scale" + bottom: "batch_norm_blob6" + top: "batch_norm_blob6" + scale_param { + bias_term: true + } +} +layer { + name: "relu6" + type: "ReLU" + bottom: "batch_norm_blob6" + top: "relu_blob6" +} +layer { + name: "conv7" + type: "Convolution" + bottom: "relu_blob6" + top: "conv_blob7" + convolution_param { + num_output: 32 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm7" + type: "BatchNorm" + bottom: "conv_blob7" + top: "batch_norm_blob7" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale7" + type: "Scale" + bottom: "batch_norm_blob7" + top: "batch_norm_blob7" + scale_param { + bias_term: true + } +} +layer { + name: "relu7" + type: "ReLU" + bottom: "batch_norm_blob7" + top: "relu_blob7" +} +layer { + name: "conv8" + type: "Convolution" + bottom: "relu_blob7" + top: "conv_blob8" + convolution_param { + num_output: 32 + bias_term: false + pad: 1 + kernel_size: 3 + group: 32 + stride: 2 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm8" + type: "BatchNorm" + bottom: "conv_blob8" + top: "batch_norm_blob8" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale8" + type: "Scale" + bottom: "batch_norm_blob8" + top: "batch_norm_blob8" + scale_param { + bias_term: true + } +} +layer { + name: "relu8" + type: "ReLU" + bottom: "batch_norm_blob8" + top: "relu_blob8" +} +layer { + name: "conv9" + type: "Convolution" + bottom: "relu_blob8" + top: "conv_blob9" + convolution_param { + num_output: 64 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm9" + type: "BatchNorm" + bottom: "conv_blob9" + top: "batch_norm_blob9" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale9" + type: "Scale" + bottom: "batch_norm_blob9" + top: "batch_norm_blob9" + scale_param { + bias_term: true + } +} +layer { + name: "relu9" + type: "ReLU" + bottom: "batch_norm_blob9" + top: "relu_blob9" +} +layer { + name: "conv10" + type: "Convolution" + bottom: "relu_blob9" + top: "conv_blob10" + convolution_param { + num_output: 64 + bias_term: false + pad: 1 + kernel_size: 3 + group: 64 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm10" + type: "BatchNorm" + bottom: "conv_blob10" + top: "batch_norm_blob10" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale10" + type: "Scale" + bottom: "batch_norm_blob10" + top: "batch_norm_blob10" + scale_param { + bias_term: true + } +} +layer { + name: "relu10" + type: "ReLU" + bottom: "batch_norm_blob10" + top: "relu_blob10" +} +layer { + name: "conv11" + type: "Convolution" + bottom: "relu_blob10" + top: "conv_blob11" + convolution_param { + num_output: 64 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm11" + type: "BatchNorm" + bottom: "conv_blob11" + top: "batch_norm_blob11" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale11" + type: "Scale" + bottom: "batch_norm_blob11" + top: "batch_norm_blob11" + scale_param { + bias_term: true + } +} +layer { + name: "relu11" + type: "ReLU" + bottom: "batch_norm_blob11" + top: "relu_blob11" +} +layer { + name: "conv12" + type: "Convolution" + bottom: "relu_blob11" + top: "conv_blob12" + convolution_param { + num_output: 64 + bias_term: false + pad: 1 + kernel_size: 3 + group: 64 + stride: 2 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm12" + type: "BatchNorm" + bottom: "conv_blob12" + top: "batch_norm_blob12" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale12" + type: "Scale" + bottom: "batch_norm_blob12" + top: "batch_norm_blob12" + scale_param { + bias_term: true + } +} +layer { + name: "relu12" + type: "ReLU" + bottom: "batch_norm_blob12" + top: "relu_blob12" +} +layer { + name: "conv13" + type: "Convolution" + bottom: "relu_blob12" + top: "conv_blob13" + convolution_param { + num_output: 128 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm13" + type: "BatchNorm" + bottom: "conv_blob13" + top: "batch_norm_blob13" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale13" + type: "Scale" + bottom: "batch_norm_blob13" + top: "batch_norm_blob13" + scale_param { + bias_term: true + } +} +layer { + name: "relu13" + type: "ReLU" + bottom: "batch_norm_blob13" + top: "relu_blob13" +} +layer { + name: "conv14" + type: "Convolution" + bottom: "relu_blob13" + top: "conv_blob14" + convolution_param { + num_output: 128 + bias_term: false + pad: 1 + kernel_size: 3 + group: 128 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm14" + type: "BatchNorm" + bottom: "conv_blob14" + top: "batch_norm_blob14" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale14" + type: "Scale" + bottom: "batch_norm_blob14" + top: "batch_norm_blob14" + scale_param { + bias_term: true + } +} +layer { + name: "relu14" + type: "ReLU" + bottom: "batch_norm_blob14" + top: "relu_blob14" +} +layer { + name: "conv15" + type: "Convolution" + bottom: "relu_blob14" + top: "conv_blob15" + convolution_param { + num_output: 128 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm15" + type: "BatchNorm" + bottom: "conv_blob15" + top: "batch_norm_blob15" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale15" + type: "Scale" + bottom: "batch_norm_blob15" + top: "batch_norm_blob15" + scale_param { + bias_term: true + } +} +layer { + name: "relu15" + type: "ReLU" + bottom: "batch_norm_blob15" + top: "relu_blob15" +} +layer { + name: "conv16" + type: "Convolution" + bottom: "relu_blob15" + top: "conv_blob16" + convolution_param { + num_output: 128 + bias_term: false + pad: 1 + kernel_size: 3 + group: 128 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm16" + type: "BatchNorm" + bottom: "conv_blob16" + top: "batch_norm_blob16" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale16" + type: "Scale" + bottom: "batch_norm_blob16" + top: "batch_norm_blob16" + scale_param { + bias_term: true + } +} +layer { + name: "relu16" + type: "ReLU" + bottom: "batch_norm_blob16" + top: "relu_blob16" +} +layer { + name: "conv17" + type: "Convolution" + bottom: "relu_blob16" + top: "conv_blob17" + convolution_param { + num_output: 128 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm17" + type: "BatchNorm" + bottom: "conv_blob17" + top: "batch_norm_blob17" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale17" + type: "Scale" + bottom: "batch_norm_blob17" + top: "batch_norm_blob17" + scale_param { + bias_term: true + } +} +layer { + name: "relu17" + type: "ReLU" + bottom: "batch_norm_blob17" + top: "relu_blob17" +} +layer { + name: "conv18" + type: "Convolution" + bottom: "relu_blob17" + top: "conv_blob18" + convolution_param { + num_output: 128 + bias_term: false + pad: 1 + kernel_size: 3 + group: 128 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm18" + type: "BatchNorm" + bottom: "conv_blob18" + top: "batch_norm_blob18" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale18" + type: "Scale" + bottom: "batch_norm_blob18" + top: "batch_norm_blob18" + scale_param { + bias_term: true + } +} +layer { + name: "relu18" + type: "ReLU" + bottom: "batch_norm_blob18" + top: "relu_blob18" +} +layer { + name: "conv19" + type: "Convolution" + bottom: "relu_blob18" + top: "conv_blob19" + convolution_param { + num_output: 128 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm19" + type: "BatchNorm" + bottom: "conv_blob19" + top: "batch_norm_blob19" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale19" + type: "Scale" + bottom: "batch_norm_blob19" + top: "batch_norm_blob19" + scale_param { + bias_term: true + } +} +layer { + name: "relu19" + type: "ReLU" + bottom: "batch_norm_blob19" + top: "relu_blob19" +} +layer { + name: "conv20" + type: "Convolution" + bottom: "relu_blob19" + top: "conv_blob20" + convolution_param { + num_output: 128 + bias_term: false + pad: 1 + kernel_size: 3 + group: 128 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm20" + type: "BatchNorm" + bottom: "conv_blob20" + top: "batch_norm_blob20" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale20" + type: "Scale" + bottom: "batch_norm_blob20" + top: "batch_norm_blob20" + scale_param { + bias_term: true + } +} +layer { + name: "relu20" + type: "ReLU" + bottom: "batch_norm_blob20" + top: "relu_blob20" +} +layer { + name: "conv21" + type: "Convolution" + bottom: "relu_blob20" + top: "conv_blob21" + convolution_param { + num_output: 128 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm21" + type: "BatchNorm" + bottom: "conv_blob21" + top: "batch_norm_blob21" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale21" + type: "Scale" + bottom: "batch_norm_blob21" + top: "batch_norm_blob21" + scale_param { + bias_term: true + } +} +layer { + name: "relu21" + type: "ReLU" + bottom: "batch_norm_blob21" + top: "relu_blob21" +} +layer { + name: "conv22" + type: "Convolution" + bottom: "relu_blob21" + top: "conv_blob22" + convolution_param { + num_output: 128 + bias_term: false + pad: 1 + kernel_size: 3 + group: 128 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm22" + type: "BatchNorm" + bottom: "conv_blob22" + top: "batch_norm_blob22" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale22" + type: "Scale" + bottom: "batch_norm_blob22" + top: "batch_norm_blob22" + scale_param { + bias_term: true + } +} +layer { + name: "relu22" + type: "ReLU" + bottom: "batch_norm_blob22" + top: "relu_blob22" +} +layer { + name: "conv23" + type: "Convolution" + bottom: "relu_blob22" + top: "conv_blob23" + convolution_param { + num_output: 128 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm23" + type: "BatchNorm" + bottom: "conv_blob23" + top: "batch_norm_blob23" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale23" + type: "Scale" + bottom: "batch_norm_blob23" + top: "batch_norm_blob23" + scale_param { + bias_term: true + } +} +layer { + name: "relu23" + type: "ReLU" + bottom: "batch_norm_blob23" + top: "relu_blob23" +} +layer { + name: "conv24" + type: "Convolution" + bottom: "relu_blob23" + top: "conv_blob24" + convolution_param { + num_output: 128 + bias_term: false + pad: 1 + kernel_size: 3 + group: 128 + stride: 2 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm24" + type: "BatchNorm" + bottom: "conv_blob24" + top: "batch_norm_blob24" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale24" + type: "Scale" + bottom: "batch_norm_blob24" + top: "batch_norm_blob24" + scale_param { + bias_term: true + } +} +layer { + name: "relu24" + type: "ReLU" + bottom: "batch_norm_blob24" + top: "relu_blob24" +} +layer { + name: "conv25" + type: "Convolution" + bottom: "relu_blob24" + top: "conv_blob25" + convolution_param { + num_output: 256 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm25" + type: "BatchNorm" + bottom: "conv_blob25" + top: "batch_norm_blob25" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale25" + type: "Scale" + bottom: "batch_norm_blob25" + top: "batch_norm_blob25" + scale_param { + bias_term: true + } +} +layer { + name: "relu25" + type: "ReLU" + bottom: "batch_norm_blob25" + top: "relu_blob25" +} +layer { + name: "conv26" + type: "Convolution" + bottom: "relu_blob25" + top: "conv_blob26" + convolution_param { + num_output: 256 + bias_term: false + pad: 1 + kernel_size: 3 + group: 256 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm26" + type: "BatchNorm" + bottom: "conv_blob26" + top: "batch_norm_blob26" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale26" + type: "Scale" + bottom: "batch_norm_blob26" + top: "batch_norm_blob26" + scale_param { + bias_term: true + } +} +layer { + name: "relu26" + type: "ReLU" + bottom: "batch_norm_blob26" + top: "relu_blob26" +} +layer { + name: "conv27" + type: "Convolution" + bottom: "relu_blob26" + top: "conv_blob27" + convolution_param { + num_output: 256 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm27" + type: "BatchNorm" + bottom: "conv_blob27" + top: "batch_norm_blob27" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale27" + type: "Scale" + bottom: "batch_norm_blob27" + top: "batch_norm_blob27" + scale_param { + bias_term: true + } +} +layer { + name: "relu27" + type: "ReLU" + bottom: "batch_norm_blob27" + top: "relu_blob27" +} +layer { + name: "conv28" + type: "Convolution" + bottom: "relu_blob11" + top: "conv_blob28" + convolution_param { + num_output: 64 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm28" + type: "BatchNorm" + bottom: "conv_blob28" + top: "batch_norm_blob28" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale28" + type: "Scale" + bottom: "batch_norm_blob28" + top: "batch_norm_blob28" + scale_param { + bias_term: true + } +} +layer { + name: "relu28" + type: "ReLU" + bottom: "batch_norm_blob28" + top: "relu_blob28" +} +layer { + name: "conv29" + type: "Convolution" + bottom: "relu_blob23" + top: "conv_blob29" + convolution_param { + num_output: 64 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm29" + type: "BatchNorm" + bottom: "conv_blob29" + top: "batch_norm_blob29" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale29" + type: "Scale" + bottom: "batch_norm_blob29" + top: "batch_norm_blob29" + scale_param { + bias_term: true + } +} +layer { + name: "relu29" + type: "ReLU" + bottom: "batch_norm_blob29" + top: "relu_blob29" +} +layer { + name: "conv30" + type: "Convolution" + bottom: "relu_blob27" + top: "conv_blob30" + convolution_param { + num_output: 64 + bias_term: false + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm30" + type: "BatchNorm" + bottom: "conv_blob30" + top: "batch_norm_blob30" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale30" + type: "Scale" + bottom: "batch_norm_blob30" + top: "batch_norm_blob30" + scale_param { + bias_term: true + } +} +layer { + name: "relu30" + type: "ReLU" + bottom: "batch_norm_blob30" + top: "relu_blob30" +} +layer { + name: "conv_transpose1" + type: "Deconvolution" + bottom: "relu_blob30" + top: "conv_transpose_blob1" + convolution_param { + num_output: 64 + bias_term: true + pad: 0 + kernel_size: 2 + group: 1 + stride: 2 + weight_filler { + type: "xavier" + } + bias_filler { + type: "constant" + } + dilation: 1 + } +} +layer { + name: "crop1" + type: "Crop" + bottom: "conv_transpose_blob1" + bottom: "relu_blob29" + top: "crop1" +} +layer { + name: "add1" + type: "Eltwise" + bottom: "relu_blob29" + bottom: "crop1" + top: "add_blob1" + eltwise_param { + operation: SUM + } +} +layer { + name: "conv31" + type: "Convolution" + bottom: "add_blob1" + top: "conv_blob31" + convolution_param { + num_output: 64 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm31" + type: "BatchNorm" + bottom: "conv_blob31" + top: "batch_norm_blob31" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale31" + type: "Scale" + bottom: "batch_norm_blob31" + top: "batch_norm_blob31" + scale_param { + bias_term: true + } +} +layer { + name: "relu31" + type: "ReLU" + bottom: "batch_norm_blob31" + top: "relu_blob31" +} +layer { + name: "conv_transpose2" + type: "Deconvolution" + bottom: "relu_blob31" + top: "conv_transpose_blob2" + convolution_param { + num_output: 64 + bias_term: true + pad: 0 + kernel_size: 2 + group: 1 + stride: 2 + weight_filler { + type: "xavier" + } + bias_filler { + type: "constant" + } + dilation: 1 + } +} +layer { + name: "crop2" + type: "Crop" + bottom: "conv_transpose_blob2" + bottom: "relu_blob28" + top: "crop2" +} +layer { + name: "add2" + type: "Eltwise" + bottom: "relu_blob28" + bottom: "crop2" + top: "add_blob2" + eltwise_param { + operation: SUM + } +} +layer { + name: "conv32" + type: "Convolution" + bottom: "add_blob2" + top: "conv_blob32" + convolution_param { + num_output: 64 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm32" + type: "BatchNorm" + bottom: "conv_blob32" + top: "batch_norm_blob32" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale32" + type: "Scale" + bottom: "batch_norm_blob32" + top: "batch_norm_blob32" + scale_param { + bias_term: true + } +} +layer { + name: "relu32" + type: "ReLU" + bottom: "batch_norm_blob32" + top: "relu_blob32" +} +layer { + name: "conv33" + type: "Convolution" + bottom: "relu_blob32" + top: "conv_blob33" + convolution_param { + num_output: 32 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm33" + type: "BatchNorm" + bottom: "conv_blob33" + top: "batch_norm_blob33" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale33" + type: "Scale" + bottom: "batch_norm_blob33" + top: "batch_norm_blob33" + scale_param { + bias_term: true + } +} +layer { + name: "conv34" + type: "Convolution" + bottom: "relu_blob32" + top: "conv_blob34" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm34" + type: "BatchNorm" + bottom: "conv_blob34" + top: "batch_norm_blob34" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale34" + type: "Scale" + bottom: "batch_norm_blob34" + top: "batch_norm_blob34" + scale_param { + bias_term: true + } +} +layer { + name: "relu33" + type: "ReLU" + bottom: "batch_norm_blob34" + top: "relu_blob33" +} +layer { + name: "conv35" + type: "Convolution" + bottom: "relu_blob33" + top: "conv_blob35" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm35" + type: "BatchNorm" + bottom: "conv_blob35" + top: "batch_norm_blob35" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale35" + type: "Scale" + bottom: "batch_norm_blob35" + top: "batch_norm_blob35" + scale_param { + bias_term: true + } +} +layer { + name: "conv36" + type: "Convolution" + bottom: "relu_blob33" + top: "conv_blob36" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm36" + type: "BatchNorm" + bottom: "conv_blob36" + top: "batch_norm_blob36" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale36" + type: "Scale" + bottom: "batch_norm_blob36" + top: "batch_norm_blob36" + scale_param { + bias_term: true + } +} +layer { + name: "relu34" + type: "ReLU" + bottom: "batch_norm_blob36" + top: "relu_blob34" +} +layer { + name: "conv37" + type: "Convolution" + bottom: "relu_blob34" + top: "conv_blob37" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm37" + type: "BatchNorm" + bottom: "conv_blob37" + top: "batch_norm_blob37" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale37" + type: "Scale" + bottom: "batch_norm_blob37" + top: "batch_norm_blob37" + scale_param { + bias_term: true + } +} +layer { + name: "cat1" + type: "Concat" + bottom: "batch_norm_blob33" + bottom: "batch_norm_blob35" + bottom: "batch_norm_blob37" + top: "cat_blob1" + concat_param { + axis: 1 + } +} +layer { + name: "relu35" + type: "ReLU" + bottom: "cat_blob1" + top: "relu_blob35" +} +layer { + name: "conv38" + type: "Convolution" + bottom: "relu_blob31" + top: "conv_blob38" + convolution_param { + num_output: 32 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm38" + type: "BatchNorm" + bottom: "conv_blob38" + top: "batch_norm_blob38" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale38" + type: "Scale" + bottom: "batch_norm_blob38" + top: "batch_norm_blob38" + scale_param { + bias_term: true + } +} +layer { + name: "conv39" + type: "Convolution" + bottom: "relu_blob31" + top: "conv_blob39" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm39" + type: "BatchNorm" + bottom: "conv_blob39" + top: "batch_norm_blob39" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale39" + type: "Scale" + bottom: "batch_norm_blob39" + top: "batch_norm_blob39" + scale_param { + bias_term: true + } +} +layer { + name: "relu36" + type: "ReLU" + bottom: "batch_norm_blob39" + top: "relu_blob36" +} +layer { + name: "conv40" + type: "Convolution" + bottom: "relu_blob36" + top: "conv_blob40" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm40" + type: "BatchNorm" + bottom: "conv_blob40" + top: "batch_norm_blob40" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale40" + type: "Scale" + bottom: "batch_norm_blob40" + top: "batch_norm_blob40" + scale_param { + bias_term: true + } +} +layer { + name: "conv41" + type: "Convolution" + bottom: "relu_blob36" + top: "conv_blob41" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm41" + type: "BatchNorm" + bottom: "conv_blob41" + top: "batch_norm_blob41" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale41" + type: "Scale" + bottom: "batch_norm_blob41" + top: "batch_norm_blob41" + scale_param { + bias_term: true + } +} +layer { + name: "relu37" + type: "ReLU" + bottom: "batch_norm_blob41" + top: "relu_blob37" +} +layer { + name: "conv42" + type: "Convolution" + bottom: "relu_blob37" + top: "conv_blob42" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm42" + type: "BatchNorm" + bottom: "conv_blob42" + top: "batch_norm_blob42" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale42" + type: "Scale" + bottom: "batch_norm_blob42" + top: "batch_norm_blob42" + scale_param { + bias_term: true + } +} +layer { + name: "cat2" + type: "Concat" + bottom: "batch_norm_blob38" + bottom: "batch_norm_blob40" + bottom: "batch_norm_blob42" + top: "cat_blob2" + concat_param { + axis: 1 + } +} +layer { + name: "relu38" + type: "ReLU" + bottom: "cat_blob2" + top: "relu_blob38" +} +layer { + name: "conv43" + type: "Convolution" + bottom: "relu_blob30" + top: "conv_blob43" + convolution_param { + num_output: 32 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm43" + type: "BatchNorm" + bottom: "conv_blob43" + top: "batch_norm_blob43" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale43" + type: "Scale" + bottom: "batch_norm_blob43" + top: "batch_norm_blob43" + scale_param { + bias_term: true + } +} +layer { + name: "conv44" + type: "Convolution" + bottom: "relu_blob30" + top: "conv_blob44" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm44" + type: "BatchNorm" + bottom: "conv_blob44" + top: "batch_norm_blob44" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale44" + type: "Scale" + bottom: "batch_norm_blob44" + top: "batch_norm_blob44" + scale_param { + bias_term: true + } +} +layer { + name: "relu39" + type: "ReLU" + bottom: "batch_norm_blob44" + top: "relu_blob39" +} +layer { + name: "conv45" + type: "Convolution" + bottom: "relu_blob39" + top: "conv_blob45" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm45" + type: "BatchNorm" + bottom: "conv_blob45" + top: "batch_norm_blob45" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale45" + type: "Scale" + bottom: "batch_norm_blob45" + top: "batch_norm_blob45" + scale_param { + bias_term: true + } +} +layer { + name: "conv46" + type: "Convolution" + bottom: "relu_blob39" + top: "conv_blob46" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm46" + type: "BatchNorm" + bottom: "conv_blob46" + top: "batch_norm_blob46" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale46" + type: "Scale" + bottom: "batch_norm_blob46" + top: "batch_norm_blob46" + scale_param { + bias_term: true + } +} +layer { + name: "relu40" + type: "ReLU" + bottom: "batch_norm_blob46" + top: "relu_blob40" +} +layer { + name: "conv47" + type: "Convolution" + bottom: "relu_blob40" + top: "conv_blob47" + convolution_param { + num_output: 16 + bias_term: false + pad: 1 + kernel_size: 3 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + dilation: 1 + } +} +layer { + name: "batch_norm47" + type: "BatchNorm" + bottom: "conv_blob47" + top: "batch_norm_blob47" + batch_norm_param { + use_global_stats: true + eps: 9.9999997e-06 + } +} +layer { + name: "bn_scale47" + type: "Scale" + bottom: "batch_norm_blob47" + top: "batch_norm_blob47" + scale_param { + bias_term: true + } +} +layer { + name: "cat3" + type: "Concat" + bottom: "batch_norm_blob43" + bottom: "batch_norm_blob45" + bottom: "batch_norm_blob47" + top: "cat_blob3" + concat_param { + axis: 1 + } +} +layer { + name: "relu41" + type: "ReLU" + bottom: "cat_blob3" + top: "relu_blob41" +} +layer { + name: "conv48" + type: "Convolution" + bottom: "relu_blob35" + top: "conv_blob48" + convolution_param { + num_output: 8 + bias_term: true + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + bias_filler { + type: "constant" + } + dilation: 1 + } +} +layer { + name: "conv49" + type: "Convolution" + bottom: "relu_blob35" + top: "conv_blob49" + convolution_param { + num_output: 4 + bias_term: true + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + bias_filler { + type: "constant" + } + dilation: 1 + } +} +layer { + name: "conv50" + type: "Convolution" + bottom: "relu_blob38" + top: "conv_blob50" + convolution_param { + num_output: 8 + bias_term: true + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + bias_filler { + type: "constant" + } + dilation: 1 + } +} +layer { + name: "conv51" + type: "Convolution" + bottom: "relu_blob38" + top: "conv_blob51" + convolution_param { + num_output: 4 + bias_term: true + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + bias_filler { + type: "constant" + } + dilation: 1 + } +} +layer { + name: "conv52" + type: "Convolution" + bottom: "relu_blob41" + top: "conv_blob52" + convolution_param { + num_output: 8 + bias_term: true + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + bias_filler { + type: "constant" + } + dilation: 1 + } +} +layer { + name: "conv53" + type: "Convolution" + bottom: "relu_blob41" + top: "conv_blob53" + convolution_param { + num_output: 4 + bias_term: true + pad: 0 + kernel_size: 1 + group: 1 + stride: 1 + weight_filler { + type: "xavier" + } + bias_filler { + type: "constant" + } + dilation: 1 + } +} +############ prior box ########### + +layer { + name: "conv4_3_norm_mbox_loc_perm" + type: "Permute" + bottom: "conv_blob48" + top: "conv4_3_norm_mbox_loc_perm" + permute_param { + order: 0 + order: 2 + order: 3 + order: 1 + } +} +layer { + name: "conv4_3_norm_mbox_loc_flat" + type: "Flatten" + bottom: "conv4_3_norm_mbox_loc_perm" + top: "conv4_3_norm_mbox_loc_flat" + flatten_param { + axis: 1 + } +} +layer { + name: "conv4_3_norm_mbox_conf_perm" + type: "Permute" + bottom: "conv_blob49" + top: "conv4_3_norm_mbox_conf_perm" + permute_param { + order: 0 + order: 2 + order: 3 + order: 1 + } +} +layer { + name: "conv4_3_norm_mbox_conf_flat" + type: "Flatten" + bottom: "conv4_3_norm_mbox_conf_perm" + top: "conv4_3_norm_mbox_conf_flat" + flatten_param { + axis: 1 + } +} +layer { + name: "conv4_3_norm_mbox_priorbox" + type: "PriorBox" + bottom: "relu_blob35" + bottom: "data" + top: "conv4_3_norm_mbox_priorbox" + prior_box_param { + min_size: 16.0 + min_size: 32.0 + clip: false + variance: 0.1 + variance: 0.1 + variance: 0.2 + variance: 0.2 + step: 8.0 + offset: 0.5 + } +} + +layer { + name: "conv5_3_norm_mbox_loc_perm" + type: "Permute" + bottom: "conv_blob50" + top: "conv5_3_norm_mbox_loc_perm" + permute_param { + order: 0 + order: 2 + order: 3 + order: 1 + } +} +layer { + name: "conv5_3_norm_mbox_loc_flat" + type: "Flatten" + bottom: "conv5_3_norm_mbox_loc_perm" + top: "conv5_3_norm_mbox_loc_flat" + flatten_param { + axis: 1 + } +} +layer { + name: "conv5_3_norm_mbox_conf_perm" + type: "Permute" + bottom: "conv_blob51" + top: "conv5_3_norm_mbox_conf_perm" + permute_param { + order: 0 + order: 2 + order: 3 + order: 1 + } +} +layer { + name: "conv5_3_norm_mbox_conf_flat" + type: "Flatten" + bottom: "conv5_3_norm_mbox_conf_perm" + top: "conv5_3_norm_mbox_conf_flat" + flatten_param { + axis: 1 + } +} +layer { + name: "conv5_3_norm_mbox_priorbox" + type: "PriorBox" + bottom: "relu_blob38" + bottom: "data" + top: "conv5_3_norm_mbox_priorbox" + prior_box_param { + min_size: 64.0 + min_size: 128.0 + clip: false + variance: 0.1 + variance: 0.1 + variance: 0.2 + variance: 0.2 + step: 16.0 + offset: 0.5 + } +} + +layer { + name: "conv6_3_norm_mbox_loc_perm" + type: "Permute" + bottom: "conv_blob52" + top: "conv6_3_norm_mbox_loc_perm" + permute_param { + order: 0 + order: 2 + order: 3 + order: 1 + } +} +layer { + name: "conv6_3_norm_mbox_loc_flat" + type: "Flatten" + bottom: "conv6_3_norm_mbox_loc_perm" + top: "conv6_3_norm_mbox_loc_flat" + flatten_param { + axis: 1 + } +} +layer { + name: "conv6_3_norm_mbox_conf_perm" + type: "Permute" + bottom: "conv_blob53" + top: "conv6_3_norm_mbox_conf_perm" + permute_param { + order: 0 + order: 2 + order: 3 + order: 1 + } +} +layer { + name: "conv6_3_norm_mbox_conf_flat" + type: "Flatten" + bottom: "conv6_3_norm_mbox_conf_perm" + top: "conv6_3_norm_mbox_conf_flat" + flatten_param { + axis: 1 + } +} +layer { + name: "conv6_3_norm_mbox_priorbox" + type: "PriorBox" + bottom: "relu_blob41" + bottom: "data" + top: "conv6_3_norm_mbox_priorbox" + prior_box_param { + min_size: 256.0 + min_size: 512.0 + clip: false + variance: 0.1 + variance: 0.1 + variance: 0.2 + variance: 0.2 + step: 32.0 + offset: 0.5 + } +} + +######################################################## +layer { + name: "mbox_loc" + type: "Concat" + bottom: "conv4_3_norm_mbox_loc_flat" + bottom: "conv5_3_norm_mbox_loc_flat" + bottom: "conv6_3_norm_mbox_loc_flat" + top: "mbox_loc" + concat_param { + axis: 1 + } +} +layer { + name: "mbox_conf" + type: "Concat" + bottom: "conv4_3_norm_mbox_conf_flat" + bottom: "conv5_3_norm_mbox_conf_flat" + bottom: "conv6_3_norm_mbox_conf_flat" + top: "mbox_conf" + concat_param { + axis: 1 + } +} +layer { + name: "mbox_priorbox" + type: "Concat" + bottom: "conv4_3_norm_mbox_priorbox" + bottom: "conv5_3_norm_mbox_priorbox" + bottom: "conv6_3_norm_mbox_priorbox" + top: "mbox_priorbox" + concat_param { + axis: 2 + } +} +layer { + name: "mbox_conf_reshape" + type: "Reshape" + bottom: "mbox_conf" + top: "mbox_conf_reshape" + reshape_param { + shape { + dim: 0 + dim: -1 + dim: 2 + } + } +} +layer { + name: "mbox_conf_softmax" + type: "Softmax" + bottom: "mbox_conf_reshape" + top: "mbox_conf_softmax" + softmax_param { + axis: 2 + } +} +layer { + name: "mbox_conf_flatten" + type: "Flatten" + bottom: "mbox_conf_softmax" + top: "mbox_conf_flatten" + flatten_param { + axis: 1 + } +} +layer { + name: "detection_out" + type: "DetectionOutput" + bottom: "mbox_loc" + bottom: "mbox_conf_flatten" + bottom: "mbox_priorbox" + top: "detection_out" + include { + phase: TEST + } + detection_output_param { + num_classes: 2 + share_location: true + background_label_id: 0 + nms_param { + nms_threshold: 0.3 + top_k: 400 + } + code_type: CENTER_SIZE + keep_top_k: 200 + confidence_threshold: 0.1 + } +} diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/anti_spoof_predict.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/anti_spoof_predict.py new file mode 100644 index 0000000..639f660 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/anti_spoof_predict.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-9 上午10:20 +# @Author : zhuying +# @Company : Minivision +# @File : anti_spoof_predict.py +# @Software : PyCharm + +import os +import cv2 +import math +import torch +import numpy as np +import torch.nn.functional as F + + +from src.model_lib.MiniFASNet import MiniFASNetV1, MiniFASNetV2,MiniFASNetV1SE,MiniFASNetV2SE +from src.data_io import transform as trans +from src.utility import get_kernel, parse_model_name + +MODEL_MAPPING = { + 'MiniFASNetV1': MiniFASNetV1, + 'MiniFASNetV2': MiniFASNetV2, + 'MiniFASNetV1SE':MiniFASNetV1SE, + 'MiniFASNetV2SE':MiniFASNetV2SE +} + + +class Detection: + def __init__(self): + # Gunakan path absolut relatif ke file ini agar bisa diimport dari mana saja + _src_dir = os.path.dirname(os.path.abspath(__file__)) # src/ + _root_dir = os.path.dirname(_src_dir) # SilentFaceAntiSpoofing/ + _detect_dir = os.path.join(_root_dir, "resources", "detection_model") + + caffemodel = os.path.join(_detect_dir, "Widerface-RetinaFace.caffemodel") + deploy = os.path.join(_detect_dir, "deploy.prototxt") + self.detector = cv2.dnn.readNetFromCaffe(deploy, caffemodel) + self.detector_confidence = 0.6 + + def get_bbox(self, img): + height, width = img.shape[0], img.shape[1] + aspect_ratio = width / height + if img.shape[1] * img.shape[0] >= 192 * 192: + img = cv2.resize(img, + (int(192 * math.sqrt(aspect_ratio)), + int(192 / math.sqrt(aspect_ratio))), interpolation=cv2.INTER_LINEAR) + + blob = cv2.dnn.blobFromImage(img, 1, mean=(104, 117, 123)) + self.detector.setInput(blob, 'data') + out = self.detector.forward('detection_out').squeeze() + max_conf_index = np.argmax(out[:, 2]) + left, top, right, bottom = out[max_conf_index, 3]*width, out[max_conf_index, 4]*height, \ + out[max_conf_index, 5]*width, out[max_conf_index, 6]*height + bbox = [int(left), int(top), int(right-left+1), int(bottom-top+1)] + return bbox + + +class AntiSpoofPredict(Detection): + def __init__(self, device_id): + super(AntiSpoofPredict, self).__init__() + self.device = torch.device("cuda:{}".format(device_id) + if torch.cuda.is_available() else "cpu") + # Cache model yang sudah di-load agar tidak reload setiap predict() + self._model_cache = {} + + def _load_model(self, model_path): + """Load model dari file .pth, gunakan cache jika sudah pernah di-load.""" + model_path = str(model_path) + if model_path in self._model_cache: + self.model = self._model_cache[model_path] + return + + # define model + model_name = os.path.basename(model_path) + h_input, w_input, model_type, _ = parse_model_name(model_name) + self.kernel_size = get_kernel(h_input, w_input,) + self.model = MODEL_MAPPING[model_type](conv6_kernel=self.kernel_size).to(self.device) + + # load model weight + state_dict = torch.load(model_path, map_location=self.device, weights_only=False) + keys = iter(state_dict) + first_layer_name = keys.__next__() + if first_layer_name.find('module.') >= 0: + from collections import OrderedDict + new_state_dict = OrderedDict() + for key, value in state_dict.items(): + name_key = key[7:] + new_state_dict[name_key] = value + self.model.load_state_dict(new_state_dict) + else: + self.model.load_state_dict(state_dict) + + self.model.eval() + # Simpan ke cache + self._model_cache[model_path] = self.model + + def predict(self, img, model_path): + test_transform = trans.Compose([ + trans.ToTensor(), + ]) + img = test_transform(img) + img = img.unsqueeze(0).to(self.device) + self._load_model(model_path) + self.model.eval() + with torch.no_grad(): + result = self.model.forward(img) + result = F.softmax(result, dim=1).cpu().numpy() + return result + + + + + + + + + + + + diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/dataset_folder.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/dataset_folder.py new file mode 100644 index 0000000..c651f41 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/dataset_folder.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-4 下午4:04 +# @Author : zhuying +# @Company : Minivision +# @File : dataset_folder.py +# @Software : PyCharm + +import cv2 +import torch +from torchvision import datasets +import numpy as np + + +def opencv_loader(path): + img = cv2.imread(path) + return img + + +class DatasetFolderFT(datasets.ImageFolder): + def __init__(self, root, transform=None, target_transform=None, + ft_width=10, ft_height=10, loader=opencv_loader): + super(DatasetFolderFT, self).__init__(root, transform, target_transform, loader) + self.root = root + self.ft_width = ft_width + self.ft_height = ft_height + + def __getitem__(self, index): + path, target = self.samples[index] + sample = self.loader(path) + # generate the FT picture of the sample + ft_sample = generate_FT(sample) + if sample is None: + print('image is None --> ', path) + if ft_sample is None: + print('FT image is None -->', path) + assert sample is not None + + ft_sample = cv2.resize(ft_sample, (self.ft_width, self.ft_height)) + ft_sample = torch.from_numpy(ft_sample).float() + ft_sample = torch.unsqueeze(ft_sample, 0) + + if self.transform is not None: + try: + sample = self.transform(sample) + except Exception as err: + print('Error Occured: %s' % err, path) + if self.target_transform is not None: + target = self.target_transform(target) + return sample, ft_sample, target + + +def generate_FT(image): + image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + f = np.fft.fft2(image) + fshift = np.fft.fftshift(f) + fimg = np.log(np.abs(fshift)+1) + maxx = -1 + minn = 100000 + for i in range(len(fimg)): + if maxx < max(fimg[i]): + maxx = max(fimg[i]) + if minn > min(fimg[i]): + minn = min(fimg[i]) + fimg = (fimg - minn+1) / (maxx - minn+1) + return fimg \ No newline at end of file diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/dataset_loader.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/dataset_loader.py new file mode 100644 index 0000000..0bfdb60 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/dataset_loader.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-4 下午3:40 +# @Author : zhuying +# @Company : Minivision +# @File : dataset_loader.py +# @Software : PyCharm + +from torch.utils.data import DataLoader +from src.data_io.dataset_folder import DatasetFolderFT +from src.data_io import transform as trans + + +def get_train_loader(conf): + train_transform = trans.Compose([ + trans.ToPILImage(), + trans.RandomResizedCrop(size=tuple(conf.input_size), + scale=(0.9, 1.1)), + trans.ColorJitter(brightness=0.4, + contrast=0.4, saturation=0.4, hue=0.1), + trans.RandomRotation(10), + trans.RandomHorizontalFlip(), + trans.ToTensor() + ]) + root_path = '{}/{}'.format(conf.train_root_path, conf.patch_info) + trainset = DatasetFolderFT(root_path, train_transform, + None, conf.ft_width, conf.ft_height) + train_loader = DataLoader( + trainset, + batch_size=conf.batch_size, + shuffle=True, + pin_memory=True, + num_workers=16) + return train_loader diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/functional.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/functional.py new file mode 100644 index 0000000..a448bc2 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/functional.py @@ -0,0 +1,589 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-4 下午6:18 +# @Author : zhuying +# @Company : Minivision +# @File : functional.py +# @Software : PyCharm + +from __future__ import division +import torch +from PIL import Image, ImageOps, ImageEnhance +try: + import accimage +except ImportError: + accimage = None +import numpy as np +import numbers +import types +import collections +import warnings + + +def _is_pil_image(img): + if accimage is not None: + return isinstance(img, (Image.Image, accimage.Image)) + else: + return isinstance(img, Image.Image) + + +def _is_tensor_image(img): + return torch.is_tensor(img) and img.ndimension() == 3 + + +def _is_numpy_image(img): + return isinstance(img, np.ndarray) and (img.ndim in {2, 3}) + + +def to_tensor(pic): + """Convert a ``PIL Image`` or ``numpy.ndarray`` to tensor. + + See ``ToTensor`` for more details. + + Args: + pic (PIL Image or numpy.ndarray): Image to be converted to tensor. + + Returns: + Tensor: Converted image. + """ + if not(_is_pil_image(pic) or _is_numpy_image(pic)): + raise TypeError('pic should be PIL Image or ndarray. Got {}'.format(type(pic))) + + if isinstance(pic, np.ndarray): + # handle numpy array + # IR image channel=1: modify by lzc --> 20190730 + if pic.ndim == 2: + pic = pic.reshape((pic.shape[0], pic.shape[1], 1)) + + img = torch.from_numpy(pic.transpose((2, 0, 1))) + # backward compatibility + # return img.float().div(255) modify by zkx + return img.float() + if accimage is not None and isinstance(pic, accimage.Image): + nppic = np.zeros([pic.channels, pic.height, pic.width], dtype=np.float32) + pic.copyto(nppic) + return torch.from_numpy(nppic) + + # handle PIL Image + if pic.mode == 'I': + img = torch.from_numpy(np.array(pic, np.int32, copy=False)) + elif pic.mode == 'I;16': + img = torch.from_numpy(np.array(pic, np.int16, copy=False)) + else: + img = torch.ByteTensor(torch.ByteStorage.from_buffer(pic.tobytes())) + # PIL image mode: 1, L, P, I, F, RGB, YCbCr, RGBA, CMYK + if pic.mode == 'YCbCr': + nchannel = 3 + elif pic.mode == 'I;16': + nchannel = 1 + else: + nchannel = len(pic.mode) + img = img.view(pic.size[1], pic.size[0], nchannel) + # put it from HWC to CHW format + # yikes, this transpose takes 80% of the loading time/CPU + img = img.transpose(0, 1).transpose(0, 2).contiguous() + if isinstance(img, torch.ByteTensor): + # return img.float().div(255) #modified by zkx + return img.float() + else: + return img + + +def to_pil_image(pic, mode=None): + """Convert a tensor or an ndarray to PIL Image. + + See :class:`~torchvision.transforms.ToPIlImage` for more details. + + Args: + pic (Tensor or numpy.ndarray): Image to be converted to PIL Image. + mode (`PIL.Image mode`_): color space and pixel depth of input data (optional). + + .. _PIL.Image mode: http://pillow.readthedocs.io/en/3.4.x/handbook/concepts.html#modes + + Returns: + PIL Image: Image converted to PIL Image. + """ + if not(_is_numpy_image(pic) or _is_tensor_image(pic)): + raise TypeError('pic should be Tensor or ndarray. Got {}.'.format(type(pic))) + + npimg = pic + if isinstance(pic, torch.FloatTensor): + pic = pic.mul(255).byte() + if torch.is_tensor(pic): + npimg = np.transpose(pic.numpy(), (1, 2, 0)) + + if not isinstance(npimg, np.ndarray): + raise TypeError('Input pic must be a torch.Tensor or NumPy ndarray, ' + + 'not {}'.format(type(npimg))) + + if npimg.shape[2] == 1: + expected_mode = None + npimg = npimg[:, :, 0] + if npimg.dtype == np.uint8: + expected_mode = 'L' + if npimg.dtype == np.int16: + expected_mode = 'I;16' + if npimg.dtype == np.int32: + expected_mode = 'I' + elif npimg.dtype == np.float32: + expected_mode = 'F' + if mode is not None and mode != expected_mode: + raise ValueError("Incorrect mode ({}) supplied for input type {}. Should be {}" + .format(mode, np.dtype, expected_mode)) + mode = expected_mode + + elif npimg.shape[2] == 4: + permitted_4_channel_modes = ['RGBA', 'CMYK'] + if mode is not None and mode not in permitted_4_channel_modes: + raise ValueError("Only modes {} are supported for 4D inputs".format(permitted_4_channel_modes)) + + if mode is None and npimg.dtype == np.uint8: + mode = 'RGBA' + else: + permitted_3_channel_modes = ['RGB', 'YCbCr', 'HSV'] + if mode is not None and mode not in permitted_3_channel_modes: + raise ValueError("Only modes {} are supported for 3D inputs".format(permitted_3_channel_modes)) + if mode is None and npimg.dtype == np.uint8: + mode = 'RGB' + + if mode is None: + raise TypeError('Input type {} is not supported'.format(npimg.dtype)) + + return Image.fromarray(npimg, mode=mode) + + +def normalize(tensor, mean, std): + """Normalize a tensor image with mean and standard deviation. + + See ``Normalize`` for more details. + + Args: + tensor (Tensor): Tensor image of size (C, H, W) to be normalized. + mean (sequence): Sequence of means for each channel. + std (sequence): Sequence of standard deviations for each channely. + + Returns: + Tensor: Normalized Tensor image. + """ + if not _is_tensor_image(tensor): + raise TypeError('tensor is not a torch image.') + + for t, m, s in zip(tensor, mean, std): + t.sub_(m).div_(s) + return tensor + + +def resize(img, size, interpolation=Image.BILINEAR): + """Resize the input PIL Image to the given size. + + Args: + img (PIL Image): Image to be resized. + size (sequence or int): Desired output size. If size is a sequence like + (h, w), the output size will be matched to this. If size is an int, + the smaller edge of the image will be matched to this number maintaing + the aspect ratio. i.e, if height > width, then image will be rescaled to + (size * height / width, size) + interpolation (int, optional): Desired interpolation. Default is + ``PIL.Image.BILINEAR`` + + Returns: + PIL Image: Resized image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + if not (isinstance(size, int) or (isinstance(size, collections.Iterable) and len(size) == 2)): + raise TypeError('Got inappropriate size arg: {}'.format(size)) + + if isinstance(size, int): + w, h = img.size + if (w <= h and w == size) or (h <= w and h == size): + return img + if w < h: + ow = size + oh = int(size * h / w) + return img.resize((ow, oh), interpolation) + else: + oh = size + ow = int(size * w / h) + return img.resize((ow, oh), interpolation) + else: + return img.resize(size[::-1], interpolation) + + +def scale(*args, **kwargs): + warnings.warn("The use of the transforms.Scale transform is deprecated, " + + "please use transforms.Resize instead.") + return resize(*args, **kwargs) + + +def pad(img, padding, fill=0): + """Pad the given PIL Image on all sides with the given "pad" value. + + Args: + img (PIL Image): Image to be padded. + padding (int or tuple): Padding on each border. If a single int is provided this + is used to pad all borders. If tuple of length 2 is provided this is the padding + on left/right and top/bottom respectively. If a tuple of length 4 is provided + this is the padding for the left, top, right and bottom borders + respectively. + fill: Pixel fill value. Default is 0. If a tuple of + length 3, it is used to fill R, G, B channels respectively. + + Returns: + PIL Image: Padded image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + if not isinstance(padding, (numbers.Number, tuple)): + raise TypeError('Got inappropriate padding arg') + if not isinstance(fill, (numbers.Number, str, tuple)): + raise TypeError('Got inappropriate fill arg') + + if isinstance(padding, collections.Sequence) and len(padding) not in [2, 4]: + raise ValueError("Padding must be an int or a 2, or 4 element tuple, not a " + + "{} element tuple".format(len(padding))) + + return ImageOps.expand(img, border=padding, fill=fill) + + +def crop(img, i, j, h, w): + """Crop the given PIL Image. + + Args: + img (PIL Image): Image to be cropped. + i: Upper pixel coordinate. + j: Left pixel coordinate. + h: Height of the cropped image. + w: Width of the cropped image. + + Returns: + PIL Image: Cropped image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + return img.crop((j, i, j + w, i + h)) + + +def center_crop(img, output_size): + if isinstance(output_size, numbers.Number): + output_size = (int(output_size), int(output_size)) + w, h = img.size + th, tw = output_size + i = int(round((h - th) / 2.)) + j = int(round((w - tw) / 2.)) + return crop(img, i, j, th, tw) + + +def resized_crop(img, i, j, h, w, size, interpolation=Image.BILINEAR): + """Crop the given PIL Image and resize it to desired size. + + Notably used in RandomResizedCrop. + + Args: + img (PIL Image): Image to be cropped. + i: Upper pixel coordinate. + j: Left pixel coordinate. + h: Height of the cropped image. + w: Width of the cropped image. + size (sequence or int): Desired output size. Same semantics as ``scale``. + interpolation (int, optional): Desired interpolation. Default is + ``PIL.Image.BILINEAR``. + Returns: + PIL Image: Cropped image. + """ + assert _is_pil_image(img), 'img should be PIL Image' + img = crop(img, i, j, h, w) + img = resize(img, size, interpolation) + return img + + +def hflip(img): + """Horizontally flip the given PIL Image. + + Args: + img (PIL Image): Image to be flipped. + + Returns: + PIL Image: Horizontall flipped image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + return img.transpose(Image.FLIP_LEFT_RIGHT) + + +def vflip(img): + """Vertically flip the given PIL Image. + + Args: + img (PIL Image): Image to be flipped. + + Returns: + PIL Image: Vertically flipped image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + return img.transpose(Image.FLIP_TOP_BOTTOM) + + +def five_crop(img, size): + """Crop the given PIL Image into four corners and the central crop. + + .. Note:: + This transform returns a tuple of images and there may be a + mismatch in the number of inputs and targets your ``Dataset`` returns. + + Args: + size (sequence or int): Desired output size of the crop. If size is an + int instead of sequence like (h, w), a square crop (size, size) is + made. + Returns: + tuple: tuple (tl, tr, bl, br, center) corresponding top left, + top right, bottom left, bottom right and center crop. + """ + if isinstance(size, numbers.Number): + size = (int(size), int(size)) + else: + assert len(size) == 2, "Please provide only two dimensions (h, w) for size." + + w, h = img.size + crop_h, crop_w = size + if crop_w > w or crop_h > h: + raise ValueError("Requested crop size {} is bigger than input size {}".format(size, + (h, w))) + tl = img.crop((0, 0, crop_w, crop_h)) + tr = img.crop((w - crop_w, 0, w, crop_h)) + bl = img.crop((0, h - crop_h, crop_w, h)) + br = img.crop((w - crop_w, h - crop_h, w, h)) + center = center_crop(img, (crop_h, crop_w)) + return (tl, tr, bl, br, center) + + +def ten_crop(img, size, vertical_flip=False): + """Crop the given PIL Image into four corners and the central crop plus the + flipped version of these (horizontal flipping is used by default). + + .. Note:: + This transform returns a tuple of images and there may be a + mismatch in the number of inputs and targets your ``Dataset`` returns. + + Args: + size (sequence or int): Desired output size of the crop. If size is an + int instead of sequence like (h, w), a square crop (size, size) is + made. + vertical_flip (bool): Use vertical flipping instead of horizontal + + Returns: + tuple: tuple (tl, tr, bl, br, center, tl_flip, tr_flip, bl_flip, + br_flip, center_flip) corresponding top left, top right, + bottom left, bottom right and center crop and same for the + flipped image. + """ + if isinstance(size, numbers.Number): + size = (int(size), int(size)) + else: + assert len(size) == 2, "Please provide only two dimensions (h, w) for size." + + first_five = five_crop(img, size) + + if vertical_flip: + img = vflip(img) + else: + img = hflip(img) + + second_five = five_crop(img, size) + return first_five + second_five + + +def adjust_brightness(img, brightness_factor): + """Adjust brightness of an Image. + + Args: + img (PIL Image): PIL Image to be adjusted. + brightness_factor (float): How much to adjust the brightness. Can be + any non negative number. 0 gives a black image, 1 gives the + original image while 2 increases the brightness by a factor of 2. + + Returns: + PIL Image: Brightness adjusted image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + enhancer = ImageEnhance.Brightness(img) + img = enhancer.enhance(brightness_factor) + return img + + +def adjust_contrast(img, contrast_factor): + """Adjust contrast of an Image. + + Args: + img (PIL Image): PIL Image to be adjusted. + contrast_factor (float): How much to adjust the contrast. Can be any + non negative number. 0 gives a solid gray image, 1 gives the + original image while 2 increases the contrast by a factor of 2. + + Returns: + PIL Image: Contrast adjusted image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + enhancer = ImageEnhance.Contrast(img) + img = enhancer.enhance(contrast_factor) + return img + + +def adjust_saturation(img, saturation_factor): + """Adjust color saturation of an image. + + Args: + img (PIL Image): PIL Image to be adjusted. + saturation_factor (float): How much to adjust the saturation. 0 will + give a black and white image, 1 will give the original image while + 2 will enhance the saturation by a factor of 2. + + Returns: + PIL Image: Saturation adjusted image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + enhancer = ImageEnhance.Color(img) + img = enhancer.enhance(saturation_factor) + return img + + +def adjust_hue(img, hue_factor): + """Adjust hue of an image. + + The image hue is adjusted by converting the image to HSV and + cyclically shifting the intensities in the hue channel (H). + The image is then converted back to original image mode. + + `hue_factor` is the amount of shift in H channel and must be in the + interval `[-0.5, 0.5]`. + + See https://en.wikipedia.org/wiki/Hue for more details on Hue. + + Args: + img (PIL Image): PIL Image to be adjusted. + hue_factor (float): How much to shift the hue channel. Should be in + [-0.5, 0.5]. 0.5 and -0.5 give complete reversal of hue channel in + HSV space in positive and negative direction respectively. + 0 means no shift. Therefore, both -0.5 and 0.5 will give an image + with complementary colors while 0 gives the original image. + + Returns: + PIL Image: Hue adjusted image. + """ + if not(-0.5 <= hue_factor <= 0.5): + raise ValueError('hue_factor is not in [-0.5, 0.5].'.format(hue_factor)) + + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + input_mode = img.mode + if input_mode in {'L', '1', 'I', 'F'}: + return img + + h, s, v = img.convert('HSV').split() + + np_h = np.array(h, dtype=np.uint8) + # uint8 addition take cares of rotation across boundaries + with np.errstate(over='ignore'): + np_h += np.uint8(hue_factor * 255) + h = Image.fromarray(np_h, 'L') + + img = Image.merge('HSV', (h, s, v)).convert(input_mode) + return img + + +def adjust_gamma(img, gamma, gain=1): + """Perform gamma correction on an image. + + Also known as Power Law Transform. Intensities in RGB mode are adjusted + based on the following equation: + + I_out = 255 * gain * ((I_in / 255) ** gamma) + + See https://en.wikipedia.org/wiki/Gamma_correction for more details. + + Args: + img (PIL Image): PIL Image to be adjusted. + gamma (float): Non negative real number. gamma larger than 1 make the + shadows darker, while gamma smaller than 1 make dark regions + lighter. + gain (float): The constant multiplier. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + if gamma < 0: + raise ValueError('Gamma should be a non-negative real number') + + input_mode = img.mode + img = img.convert('RGB') + + np_img = np.array(img, dtype=np.float32) + np_img = 255 * gain * ((np_img / 255) ** gamma) + np_img = np.uint8(np.clip(np_img, 0, 255)) + + img = Image.fromarray(np_img, 'RGB').convert(input_mode) + return img + + +def rotate(img, angle, resample=False, expand=False, center=None): + """Rotate the image by angle and then (optionally) translate it by (n_columns, n_rows) + + + Args: + img (PIL Image): PIL Image to be rotated. + angle ({float, int}): In degrees degrees counter clockwise order. + resample ({PIL.Image.NEAREST, PIL.Image.BILINEAR, PIL.Image.BICUBIC}, optional): + An optional resampling filter. + See http://pillow.readthedocs.io/en/3.4.x/handbook/concepts.html#filters + If omitted, or if the image has mode "1" or "P", it is set to PIL.Image.NEAREST. + expand (bool, optional): Optional expansion flag. + If true, expands the output image to make it large enough to hold the entire rotated image. + If false or omitted, make the output image the same size as the input image. + Note that the expand flag assumes rotation around the center and no translation. + center (2-tuple, optional): Optional center of rotation. + Origin is the upper left corner. + Default is the center of the image. + """ + + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + return img.rotate(angle, resample, expand, center) + + +def to_grayscale(img, num_output_channels=1): + """Convert image to grayscale version of image. + + Args: + img (PIL Image): Image to be converted to grayscale. + + Returns: + PIL Image: Grayscale version of the image. + if num_output_channels == 1 : returned image is single channel + if num_output_channels == 3 : returned image is 3 channel with r == g == b + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + if num_output_channels == 1: + img = img.convert('L') + elif num_output_channels == 3: + img = img.convert('L') + np_img = np.array(img, dtype=np.uint8) + np_img = np.dstack([np_img, np_img, np_img]) + img = Image.fromarray(np_img, 'RGB') + else: + raise ValueError('num_output_channels should be either 1 or 3') + + return img diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/transform.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/transform.py new file mode 100644 index 0000000..5aa1654 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/data_io/transform.py @@ -0,0 +1,347 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-4 下午4:19 +# @Author : zhuying +# @Company : Minivision +# @File : transform.py +# @Software : PyCharm + +from __future__ import division +import math +import random +from PIL import Image +try: + import accimage +except ImportError: + accimage = None +import numpy as np +import numbers +import types + +from src.data_io import functional as F + +__all__ = ["Compose", "ToTensor", "ToPILImage", "Normalize", "RandomHorizontalFlip", + "Lambda", "RandomResizedCrop", "ColorJitter", "RandomRotation"] + + +class Compose(object): + """Composes several transforms together. + + Args: + transforms (list of ``Transform`` objects): list of transforms to compose. + + Example: + >>> transforms.Compose([ + >>> transforms.CenterCrop(10), + >>> transforms.ToTensor(), + >>> ]) + """ + + def __init__(self, transforms): + self.transforms = transforms + + def __call__(self, img): + for t in self.transforms: + img = t(img) + return img + + +class ToTensor(object): + + """Convert a ``PIL Image`` or ``numpy.ndarray`` to tensor. + + Converts a PIL Image or numpy.ndarray (H x W x C) in the range + [0, 255] to a torch.FloatTensor of shape (C x H x W) in the range [0.0, 1.0]. + """ + + def __call__(self, pic): + """ + Args: + pic (PIL Image or numpy.ndarray): Image to be converted to tensor. + + Returns: + Tensor: Converted image. + """ + return F.to_tensor(pic) + + +class Lambda(object): + """Apply a user-defined lambda as a transform. + + Args: + lambd (function): Lambda/function to be used for transform. + """ + + def __init__(self, lambd): + assert isinstance(lambd, types.LambdaType) + self.lambd = lambd + + def __call__(self, img): + return self.lambd(img) + + +class ToPILImage(object): + """Convert a tensor or an ndarray to PIL Image. + + Converts a torch.*Tensor of shape C x H x W or a numpy ndarray of shape + H x W x C to a PIL Image while preserving the value range. + + Args: + mode (`PIL.Image mode`_): color space and pixel depth of input data (optional). + If ``mode`` is ``None`` (default) there are some assumptions made about the input data: + 1. If the input has 3 channels, the ``mode`` is assumed to be ``RGB``. + 2. If the input has 4 channels, the ``mode`` is assumed to be ``RGBA``. + 3. If the input has 1 channel, the ``mode`` is determined by the data type (i,e, + ``int``, ``float``, ``short``). + + .. _PIL.Image mode: http://pillow.readthedocs.io/en/3.4.x/handbook/concepts.html#modes + """ + def __init__(self, mode=None): + self.mode = mode + + def __call__(self, pic): + """ + Args: + pic (Tensor or numpy.ndarray): Image to be converted to PIL Image. + + Returns: + PIL Image: Image converted to PIL Image. + + """ + return F.to_pil_image(pic, self.mode) + + +class Normalize(object): + """Normalize an tensor image with mean and standard deviation. + Given mean: ``(M1,...,Mn)`` and std: ``(S1,..,Sn)`` for ``n`` channels, this transform + will normalize each channel of the input ``torch.*Tensor`` i.e. + ``input[channel] = (input[channel] - mean[channel]) / std[channel]`` + + Args: + mean (sequence): Sequence of means for each channel. + std (sequence): Sequence of standard deviations for each channel. + """ + + def __init__(self, mean, std): + self.mean = mean + self.std = std + + def __call__(self, tensor): + """ + Args: + tensor (Tensor): Tensor image of size (C, H, W) to be normalized. + + Returns: + Tensor: Normalized Tensor image. + """ + return F.normalize(tensor, self.mean, self.std) + + +class RandomHorizontalFlip(object): + """Horizontally flip the given PIL Image randomly with a probability of 0.5.""" + + def __call__(self, img): + """ + Args: + img (PIL Image): Image to be flipped. + + Returns: + PIL Image: Randomly flipped image. + """ + if random.random() < 0.5: + return F.hflip(img) + return img + + +class RandomResizedCrop(object): + """Crop the given PIL Image to random size and aspect ratio. + + A crop of random size (default: of 0.08 to 1.0) of the original size and a random + aspect ratio (default: of 3/4 to 4/3) of the original aspect ratio is made. This crop + is finally resized to given size. + This is popularly used to train the Inception networks. + + Args: + size: expected output size of each edge + scale: range of size of the origin size cropped + ratio: range of aspect ratio of the origin aspect ratio cropped + interpolation: Default: PIL.Image.BILINEAR + """ + + def __init__(self, size, scale=(0.08, 1.0), ratio=(3. / 4., 4. / 3.), interpolation=Image.BILINEAR): + if isinstance(size, tuple): + self.size = size + else: + self.size = (size, size) + self.interpolation = interpolation + self.scale = scale + self.ratio = ratio + + @staticmethod + def get_params(img, scale, ratio): + """Get parameters for ``crop`` for a random sized crop. + + Args: + img (PIL Image): Image to be cropped. + scale (tuple): range of size of the origin size cropped + ratio (tuple): range of aspect ratio of the origin aspect ratio cropped + + Returns: + tuple: params (i, j, h, w) to be passed to ``crop`` for a random + sized crop. + """ + for attempt in range(10): + area = img.size[0] * img.size[1] + target_area = random.uniform(*scale) * area + aspect_ratio = random.uniform(*ratio) + + w = int(round(math.sqrt(target_area * aspect_ratio))) + h = int(round(math.sqrt(target_area / aspect_ratio))) + + if random.random() < 0.5: + w, h = h, w + + if w <= img.size[0] and h <= img.size[1]: + i = random.randint(0, img.size[1] - h) + j = random.randint(0, img.size[0] - w) + return i, j, h, w + + # Fallback + w = min(img.size[0], img.size[1]) + i = (img.size[1] - w) // 2 + j = (img.size[0] - w) // 2 + return i, j, w, w + + def __call__(self, img): + """ + Args: + img (PIL Image): Image to be flipped. + + Returns: + PIL Image: Randomly cropped and resize image. + """ + i, j, h, w = self.get_params(img, self.scale, self.ratio) + return F.resized_crop(img, i, j, h, w, self.size, self.interpolation) + + +class ColorJitter(object): + """Randomly change the brightness, contrast and saturation of an image. + + Args: + brightness (float): How much to jitter brightness. brightness_factor + is chosen uniformly from [max(0, 1 - brightness), 1 + brightness]. + contrast (float): How much to jitter contrast. contrast_factor + is chosen uniformly from [max(0, 1 - contrast), 1 + contrast]. + saturation (float): How much to jitter saturation. saturation_factor + is chosen uniformly from [max(0, 1 - saturation), 1 + saturation]. + hue(float): How much to jitter hue. hue_factor is chosen uniformly from + [-hue, hue]. Should be >=0 and <= 0.5. + """ + def __init__(self, brightness=0, contrast=0, saturation=0, hue=0): + self.brightness = brightness + self.contrast = contrast + self.saturation = saturation + self.hue = hue + + @staticmethod + def get_params(brightness, contrast, saturation, hue): + """Get a randomized transform to be applied on image. + + Arguments are same as that of __init__. + + Returns: + Transform which randomly adjusts brightness, contrast and + saturation in a random order. + """ + transforms = [] + if brightness > 0: + brightness_factor = np.random.uniform(max(0, 1 - brightness), 1 + brightness) + transforms.append(Lambda(lambda img: F.adjust_brightness(img, brightness_factor))) + + if contrast > 0: + contrast_factor = np.random.uniform(max(0, 1 - contrast), 1 + contrast) + transforms.append(Lambda(lambda img: F.adjust_contrast(img, contrast_factor))) + + if saturation > 0: + saturation_factor = np.random.uniform(max(0, 1 - saturation), 1 + saturation) + transforms.append(Lambda(lambda img: F.adjust_saturation(img, saturation_factor))) + + if hue > 0: + hue_factor = np.random.uniform(-hue, hue) + transforms.append(Lambda(lambda img: F.adjust_hue(img, hue_factor))) + + np.random.shuffle(transforms) + transform = Compose(transforms) + + return transform + + def __call__(self, img): + """ + Args: + img (PIL Image): Input image. + + Returns: + PIL Image: Color jittered image. + """ + transform = self.get_params(self.brightness, self.contrast, + self.saturation, self.hue) + return transform(img) + + +class RandomRotation(object): + """Rotate the image by angle. + + Args: + degrees (sequence or float or int): Range of degrees to select from. + If degrees is a number instead of sequence like (min, max), the range of degrees + will be (-degrees, +degrees). + resample ({PIL.Image.NEAREST, PIL.Image.BILINEAR, PIL.Image.BICUBIC}, optional): + An optional resampling filter. + See http://pillow.readthedocs.io/en/3.4.x/handbook/concepts.html#filters + If omitted, or if the image has mode "1" or "P", it is set to PIL.Image.NEAREST. + expand (bool, optional): Optional expansion flag. + If true, expands the output to make it large enough to hold the entire rotated image. + If false or omitted, make the output image the same size as the input image. + Note that the expand flag assumes rotation around the center and no translation. + center (2-tuple, optional): Optional center of rotation. + Origin is the upper left corner. + Default is the center of the image. + """ + + def __init__(self, degrees, resample=False, expand=False, center=None): + if isinstance(degrees, numbers.Number): + if degrees < 0: + raise ValueError("If degrees is a single number, it must be positive.") + self.degrees = (-degrees, degrees) + else: + if len(degrees) != 2: + raise ValueError("If degrees is a sequence, it must be of len 2.") + self.degrees = degrees + + self.resample = resample + self.expand = expand + self.center = center + + @staticmethod + def get_params(degrees): + """Get parameters for ``rotate`` for a random rotation. + + Returns: + sequence: params to be passed to ``rotate`` for random rotation. + """ + angle = np.random.uniform(degrees[0], degrees[1]) + + return angle + + def __call__(self, img): + """ + img (PIL Image): Image to be rotated. + + Returns: + PIL Image: Rotated image. + """ + + angle = self.get_params(self.degrees) + + return F.rotate(img, angle, self.resample, self.expand, self.center) + + diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/default_config.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/default_config.py new file mode 100644 index 0000000..d630c6e --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/default_config.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-4 上午9:12 +# @Author : zhuying +# @Company : Minivision +# @File : default_config.py +# @Software : PyCharm +# --*-- coding: utf-8 --*-- +""" +default config for training +""" + +import torch +from datetime import datetime +from easydict import EasyDict +from src.utility import make_if_not_exist, get_width_height, get_kernel + + +def get_default_config(): + conf = EasyDict() + + # ----------------------training--------------- + conf.lr = 1e-1 + # [9, 13, 15] + conf.milestones = [10, 15, 22] # down learing rate + conf.gamma = 0.1 + conf.epochs = 25 + conf.momentum = 0.9 + conf.batch_size = 1024 + + # model + conf.num_classes = 3 + conf.input_channel = 3 + conf.embedding_size = 128 + + # dataset + conf.train_root_path = './datasets/rgb_image' + + # save file path + conf.snapshot_dir_path = './saved_logs/snapshot' + + # log path + conf.log_path = './saved_logs/jobs' + # tensorboard + conf.board_loss_every = 10 + # save model/iter + conf.save_every = 30 + + return conf + + +def update_config(args, conf): + conf.devices = args.devices + conf.patch_info = args.patch_info + w_input, h_input = get_width_height(args.patch_info) + conf.input_size = [h_input, w_input] + conf.kernel_size = get_kernel(h_input, w_input) + conf.device = "cuda:{}".format(conf.devices[0]) if torch.cuda.is_available() else "cpu" + + # resize fourier image size + conf.ft_height = 2*conf.kernel_size[0] + conf.ft_width = 2*conf.kernel_size[1] + current_time = datetime.now().strftime('%b%d_%H-%M-%S') + job_name = 'Anti_Spoofing_{}'.format(args.patch_info) + log_path = '{}/{}/{} '.format(conf.log_path, job_name, current_time) + snapshot_dir = '{}/{}'.format(conf.snapshot_dir_path, job_name) + + make_if_not_exist(snapshot_dir) + make_if_not_exist(log_path) + + conf.model_path = snapshot_dir + conf.log_path = log_path + conf.job_name = job_name + return conf diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/generate_patches.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/generate_patches.py new file mode 100644 index 0000000..0056269 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/generate_patches.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-9 下午3:06 +# @Author : zhuying +# @Company : Minivision +# @File : test.py +# @Software : PyCharm +""" +Create patch from original input image by using bbox coordinate +""" + +import cv2 +import numpy as np + + +class CropImage: + @staticmethod + def _get_new_box(src_w, src_h, bbox, scale): + x = bbox[0] + y = bbox[1] + box_w = bbox[2] + box_h = bbox[3] + + scale = min((src_h-1)/box_h, min((src_w-1)/box_w, scale)) + + new_width = box_w * scale + new_height = box_h * scale + center_x, center_y = box_w/2+x, box_h/2+y + + left_top_x = center_x-new_width/2 + left_top_y = center_y-new_height/2 + right_bottom_x = center_x+new_width/2 + right_bottom_y = center_y+new_height/2 + + if left_top_x < 0: + right_bottom_x -= left_top_x + left_top_x = 0 + + if left_top_y < 0: + right_bottom_y -= left_top_y + left_top_y = 0 + + if right_bottom_x > src_w-1: + left_top_x -= right_bottom_x-src_w+1 + right_bottom_x = src_w-1 + + if right_bottom_y > src_h-1: + left_top_y -= right_bottom_y-src_h+1 + right_bottom_y = src_h-1 + + return int(left_top_x), int(left_top_y),\ + int(right_bottom_x), int(right_bottom_y) + + def crop(self, org_img, bbox, scale, out_w, out_h, crop=True): + + if not crop: + dst_img = cv2.resize(org_img, (out_w, out_h)) + else: + src_h, src_w, _ = np.shape(org_img) + left_top_x, left_top_y, \ + right_bottom_x, right_bottom_y = self._get_new_box(src_w, src_h, bbox, scale) + + img = org_img[left_top_y: right_bottom_y+1, + left_top_x: right_bottom_x+1] + dst_img = cv2.resize(img, (out_w, out_h)) + return dst_img diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/model_lib/MiniFASNet.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/model_lib/MiniFASNet.py new file mode 100644 index 0000000..548f773 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/model_lib/MiniFASNet.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-3 下午4:45 +# @Author : zhuying +# @Company : Minivision +# @File : MiniFASNet.py +# @Software : PyCharm +import torch +import torch.nn.functional as F +from torch.nn import Linear, Conv2d, BatchNorm1d, BatchNorm2d, PReLU, ReLU, Sigmoid, \ + AdaptiveAvgPool2d, Sequential, Module + + +class L2Norm(Module): + def forward(self, input): + return F.normalize(input) + + +class Flatten(Module): + def forward(self, input): + return input.view(input.size(0), -1) + + +class Conv_block(Module): + def __init__(self, in_c, out_c, kernel=(1, 1), stride=(1, 1), padding=(0, 0), groups=1): + super(Conv_block, self).__init__() + self.conv = Conv2d(in_c, out_c, kernel_size=kernel, groups=groups, + stride=stride, padding=padding, bias=False) + self.bn = BatchNorm2d(out_c) + self.prelu = PReLU(out_c) + + def forward(self, x): + x = self.conv(x) + x = self.bn(x) + x = self.prelu(x) + return x + + +class Linear_block(Module): + def __init__(self, in_c, out_c, kernel=(1, 1), stride=(1, 1), padding=(0, 0), groups=1): + super(Linear_block, self).__init__() + self.conv = Conv2d(in_c, out_channels=out_c, kernel_size=kernel, + groups=groups, stride=stride, padding=padding, bias=False) + self.bn = BatchNorm2d(out_c) + + def forward(self, x): + x = self.conv(x) + x = self.bn(x) + return x + + +class Depth_Wise(Module): + def __init__(self, c1, c2, c3, residual=False, kernel=(3, 3), stride=(2, 2), padding=(1, 1), groups=1): + super(Depth_Wise, self).__init__() + c1_in, c1_out = c1 + c2_in, c2_out = c2 + c3_in, c3_out = c3 + self.conv = Conv_block(c1_in, out_c=c1_out, kernel=(1, 1), padding=(0, 0), stride=(1, 1)) + self.conv_dw = Conv_block(c2_in, c2_out, groups=c2_in, kernel=kernel, padding=padding, stride=stride) + self.project = Linear_block(c3_in, c3_out, kernel=(1, 1), padding=(0, 0), stride=(1, 1)) + self.residual = residual + + def forward(self, x): + if self.residual: + short_cut = x + x = self.conv(x) + x = self.conv_dw(x) + x = self.project(x) + if self.residual: + output = short_cut + x + else: + output = x + return output + + +class Residual(Module): + def __init__(self, c1, c2, c3, num_block, groups, kernel=(3, 3), stride=(1, 1), padding=(1, 1)): + super(Residual, self).__init__() + modules = [] + for i in range(num_block): + c1_tuple = c1[i] + c2_tuple = c2[i] + c3_tuple = c3[i] + modules.append(Depth_Wise(c1_tuple, c2_tuple, c3_tuple, residual=True, + kernel=kernel, padding=padding, stride=stride, groups=groups)) + self.model = Sequential(*modules) + + def forward(self, x): + return self.model(x) + + +class SEModule(Module): + def __init__(self, channels, reduction): + super(SEModule, self).__init__() + self.avg_pool = AdaptiveAvgPool2d(1) + self.fc1 = Conv2d( + channels, channels // reduction, kernel_size=1, padding=0, bias=False) + self.bn1 = BatchNorm2d(channels // reduction) + self.relu = ReLU(inplace=True) + self.fc2 = Conv2d( + channels // reduction, channels, kernel_size=1, padding=0, bias=False) + self.bn2 = BatchNorm2d(channels) + self.sigmoid = Sigmoid() + + def forward(self, x): + module_input = x + x = self.avg_pool(x) + x = self.fc1(x) + x = self.bn1(x) + x = self.relu(x) + x = self.fc2(x) + x = self.bn2(x) + x = self.sigmoid(x) + return module_input * x + + +class ResidualSE(Module): + def __init__(self, c1, c2, c3, num_block, groups, kernel=(3, 3), stride=(1, 1), padding=(1, 1), se_reduct=4): + super(ResidualSE, self).__init__() + modules = [] + for i in range(num_block): + c1_tuple = c1[i] + c2_tuple = c2[i] + c3_tuple = c3[i] + if i == num_block-1: + modules.append( + Depth_Wise_SE(c1_tuple, c2_tuple, c3_tuple, residual=True, kernel=kernel, padding=padding, stride=stride, + groups=groups, se_reduct=se_reduct)) + else: + modules.append(Depth_Wise(c1_tuple, c2_tuple, c3_tuple, residual=True, kernel=kernel, padding=padding, + stride=stride, groups=groups)) + self.model = Sequential(*modules) + + def forward(self, x): + return self.model(x) + + +class Depth_Wise_SE(Module): + def __init__(self, c1, c2, c3, residual=False, kernel=(3, 3), stride=(2, 2), padding=(1, 1), groups=1, se_reduct=8): + super(Depth_Wise_SE, self).__init__() + c1_in, c1_out = c1 + c2_in, c2_out = c2 + c3_in, c3_out = c3 + self.conv = Conv_block(c1_in, out_c=c1_out, kernel=(1, 1), padding=(0, 0), stride=(1, 1)) + self.conv_dw = Conv_block(c2_in, c2_out, groups=c2_in, kernel=kernel, padding=padding, stride=stride) + self.project = Linear_block(c3_in, c3_out, kernel=(1, 1), padding=(0, 0), stride=(1, 1)) + self.residual = residual + self.se_module = SEModule(c3_out, se_reduct) + + def forward(self, x): + if self.residual: + short_cut = x + x = self.conv(x) + x = self.conv_dw(x) + x = self.project(x) + if self.residual: + x = self.se_module(x) + output = short_cut + x + else: + output = x + return output + + +class MiniFASNet(Module): + def __init__(self, keep, embedding_size, conv6_kernel=(7, 7), + drop_p=0.0, num_classes=3, img_channel=3): + super(MiniFASNet, self).__init__() + self.embedding_size = embedding_size + + self.conv1 = Conv_block(img_channel, keep[0], kernel=(3, 3), stride=(2, 2), padding=(1, 1)) + self.conv2_dw = Conv_block(keep[0], keep[1], kernel=(3, 3), stride=(1, 1), padding=(1, 1), groups=keep[1]) + + c1 = [(keep[1], keep[2])] + c2 = [(keep[2], keep[3])] + c3 = [(keep[3], keep[4])] + + self.conv_23 = Depth_Wise(c1[0], c2[0], c3[0], kernel=(3, 3), stride=(2, 2), padding=(1, 1), groups=keep[3]) + + c1 = [(keep[4], keep[5]), (keep[7], keep[8]), (keep[10], keep[11]), (keep[13], keep[14])] + c2 = [(keep[5], keep[6]), (keep[8], keep[9]), (keep[11], keep[12]), (keep[14], keep[15])] + c3 = [(keep[6], keep[7]), (keep[9], keep[10]), (keep[12], keep[13]), (keep[15], keep[16])] + + self.conv_3 = Residual(c1, c2, c3, num_block=4, groups=keep[4], kernel=(3, 3), stride=(1, 1), padding=(1, 1)) + + c1 = [(keep[16], keep[17])] + c2 = [(keep[17], keep[18])] + c3 = [(keep[18], keep[19])] + + self.conv_34 = Depth_Wise(c1[0], c2[0], c3[0], kernel=(3, 3), stride=(2, 2), padding=(1, 1), groups=keep[19]) + + c1 = [(keep[19], keep[20]), (keep[22], keep[23]), (keep[25], keep[26]), (keep[28], keep[29]), + (keep[31], keep[32]), (keep[34], keep[35])] + c2 = [(keep[20], keep[21]), (keep[23], keep[24]), (keep[26], keep[27]), (keep[29], keep[30]), + (keep[32], keep[33]), (keep[35], keep[36])] + c3 = [(keep[21], keep[22]), (keep[24], keep[25]), (keep[27], keep[28]), (keep[30], keep[31]), + (keep[33], keep[34]), (keep[36], keep[37])] + + self.conv_4 = Residual(c1, c2, c3, num_block=6, groups=keep[19], kernel=(3, 3), stride=(1, 1), padding=(1, 1)) + + c1 = [(keep[37], keep[38])] + c2 = [(keep[38], keep[39])] + c3 = [(keep[39], keep[40])] + + self.conv_45 = Depth_Wise(c1[0], c2[0], c3[0], kernel=(3, 3), stride=(2, 2), padding=(1, 1), groups=keep[40]) + + c1 = [(keep[40], keep[41]), (keep[43], keep[44])] + c2 = [(keep[41], keep[42]), (keep[44], keep[45])] + c3 = [(keep[42], keep[43]), (keep[45], keep[46])] + + self.conv_5 = Residual(c1, c2, c3, num_block=2, groups=keep[40], kernel=(3, 3), stride=(1, 1), padding=(1, 1)) + self.conv_6_sep = Conv_block(keep[46], keep[47], kernel=(1, 1), stride=(1, 1), padding=(0, 0)) + self.conv_6_dw = Linear_block(keep[47], keep[48], groups=keep[48], kernel=conv6_kernel, stride=(1, 1), padding=(0, 0)) + self.conv_6_flatten = Flatten() + self.linear = Linear(512, embedding_size, bias=False) + self.bn = BatchNorm1d(embedding_size) + self.drop = torch.nn.Dropout(p=drop_p) + self.prob = Linear(embedding_size, num_classes, bias=False) + + def forward(self, x): + out = self.conv1(x) + out = self.conv2_dw(out) + out = self.conv_23(out) + out = self.conv_3(out) + out = self.conv_34(out) + out = self.conv_4(out) + out = self.conv_45(out) + out = self.conv_5(out) + out = self.conv_6_sep(out) + out = self.conv_6_dw(out) + out = self.conv_6_flatten(out) + if self.embedding_size != 512: + out = self.linear(out) + out = self.bn(out) + out = self.drop(out) + out = self.prob(out) + return out + + +class MiniFASNetSE(MiniFASNet): + def __init__(self, keep, embedding_size, conv6_kernel=(7, 7),drop_p=0.75, num_classes=4, img_channel=3): + super(MiniFASNetSE, self).__init__(keep=keep, embedding_size=embedding_size, conv6_kernel=conv6_kernel, + drop_p=drop_p, num_classes=num_classes, img_channel=img_channel) + + c1 = [(keep[4], keep[5]), (keep[7], keep[8]), (keep[10], keep[11]), (keep[13], keep[14])] + c2 = [(keep[5], keep[6]), (keep[8], keep[9]), (keep[11], keep[12]), (keep[14], keep[15])] + c3 = [(keep[6], keep[7]), (keep[9], keep[10]), (keep[12], keep[13]), (keep[15], keep[16])] + + self.conv_3 = ResidualSE(c1, c2, c3, num_block=4, groups=keep[4], kernel=(3, 3), stride=(1, 1), padding=(1, 1)) + + c1 = [(keep[19], keep[20]), (keep[22], keep[23]), (keep[25], keep[26]), (keep[28], keep[29]), + (keep[31], keep[32]), (keep[34], keep[35])] + c2 = [(keep[20], keep[21]), (keep[23], keep[24]), (keep[26], keep[27]), (keep[29], keep[30]), + (keep[32], keep[33]), (keep[35], keep[36])] + c3 = [(keep[21], keep[22]), (keep[24], keep[25]), (keep[27], keep[28]), (keep[30], keep[31]), + (keep[33], keep[34]), (keep[36], keep[37])] + + self.conv_4 = ResidualSE(c1, c2, c3, num_block=6, groups=keep[19], kernel=(3, 3), stride=(1, 1), padding=(1, 1)) + + c1 = [(keep[40], keep[41]), (keep[43], keep[44])] + c2 = [(keep[41], keep[42]), (keep[44], keep[45])] + c3 = [(keep[42], keep[43]), (keep[45], keep[46])] + self.conv_5 = ResidualSE(c1, c2, c3, num_block=2, groups=keep[40], kernel=(3, 3), stride=(1, 1), padding=(1, 1)) + + + +keep_dict = {'1.8M': [32, 32, 103, 103, 64, 13, 13, 64, 26, 26, + 64, 13, 13, 64, 52, 52, 64, 231, 231, 128, + 154, 154, 128, 52, 52, 128, 26, 26, 128, 52, + 52, 128, 26, 26, 128, 26, 26, 128, 308, 308, + 128, 26, 26, 128, 26, 26, 128, 512, 512], + + '1.8M_': [32, 32, 103, 103, 64, 13, 13, 64, 13, 13, 64, 13, + 13, 64, 13, 13, 64, 231, 231, 128, 231, 231, 128, 52, + 52, 128, 26, 26, 128, 77, 77, 128, 26, 26, 128, 26, 26, + 128, 308, 308, 128, 26, 26, 128, 26, 26, 128, 512, 512] + } + + +# (80x80) flops: 0.044, params: 0.41 +def MiniFASNetV1(embedding_size=128, conv6_kernel=(7, 7), + drop_p=0.2, num_classes=3, img_channel=3): + return MiniFASNet(keep_dict['1.8M'], embedding_size, conv6_kernel, drop_p, num_classes, img_channel) + + +# (80x80) flops: 0.044, params: 0.43 +def MiniFASNetV2(embedding_size=128, conv6_kernel=(7, 7), + drop_p=0.2, num_classes=3, img_channel=3): + return MiniFASNet(keep_dict['1.8M_'], embedding_size, conv6_kernel, drop_p, num_classes, img_channel) + +def MiniFASNetV1SE(embedding_size=128, conv6_kernel=(7, 7), + drop_p=0.75, num_classes=3, img_channel=3): + return MiniFASNetSE(keep_dict['1.8M'], embedding_size, conv6_kernel,drop_p, num_classes, img_channel) + +# (80x80) flops: 0.044, params: 0.43 +def MiniFASNetV2SE(embedding_size=128, conv6_kernel=(7, 7), + drop_p=0.75, num_classes=4, img_channel=3): + return MiniFASNetSE(keep_dict['1.8M_'], embedding_size, conv6_kernel,drop_p, num_classes, img_channel) diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/model_lib/MultiFTNet.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/model_lib/MultiFTNet.py new file mode 100644 index 0000000..f934df4 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/model_lib/MultiFTNet.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-3 下午5:14 +# @Author : zhuying +# @Company : Minivision +# @File : MultiFTNet.py +# @Software : PyCharm +from torch import nn +import torch.nn.functional as F +from src.model_lib.MiniFASNet import MiniFASNetV1,MiniFASNetV2,MiniFASNetV1SE,MiniFASNetV2SE + + +class FTGenerator(nn.Module): + def __init__(self, in_channels=48, out_channels=1): + super(FTGenerator, self).__init__() + + self.ft = nn.Sequential( + nn.Conv2d(in_channels, 128, kernel_size=(3, 3), padding=1), + nn.BatchNorm2d(128), + nn.ReLU(inplace=True), + + nn.Conv2d(128, 64, kernel_size=(3, 3), padding=1), + nn.BatchNorm2d(64), + nn.ReLU(inplace=True), + + nn.Conv2d(64, out_channels, kernel_size=(3, 3), padding=1), + nn.BatchNorm2d(out_channels), + nn.ReLU(inplace=True) + ) + + def forward(self, x): + return self.ft(x) + + +class MultiFTNet(nn.Module): + def __init__(self, img_channel=3, num_classes=3, embedding_size=128, conv6_kernel=(5, 5)): + super(MultiFTNet, self).__init__() + self.img_channel = img_channel + self.num_classes = num_classes + self.model = MiniFASNetV2SE(embedding_size=embedding_size, conv6_kernel=conv6_kernel, + num_classes=num_classes, img_channel=img_channel) + self.FTGenerator = FTGenerator(in_channels=128) + self._initialize_weights() + + def _initialize_weights(self): + for m in self.modules(): + if isinstance(m, nn.Conv2d): + nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') + if m.bias is not None: + nn.init.constant_(m.bias, 0) + elif isinstance(m, (nn.BatchNorm2d, nn.BatchNorm1d, nn.GroupNorm)): + nn.init.constant_(m.weight, 1) + nn.init.constant_(m.bias, 0) + elif isinstance(m, nn.Linear): + nn.init.normal_(m.weight, std=0.001) + if m.bias is not None: + nn.init.constant_(m.bias, 0) + + def forward(self, x): + x = self.model.conv1(x) + x = self.model.conv2_dw(x) + x = self.model.conv_23(x) + x = self.model.conv_3(x) + x = self.model.conv_34(x) + x = self.model.conv_4(x) + x1 = self.model.conv_45(x) + x1 = self.model.conv_5(x1) + x1 = self.model.conv_6_sep(x1) + x1 = self.model.conv_6_dw(x1) + x1 = self.model.conv_6_flatten(x1) + x1 = self.model.linear(x1) + x1 = self.model.bn(x1) + x1 = self.model.drop(x1) + cls = self.model.prob(x1) + + if self.training: + ft = self.FTGenerator(x) + return cls, ft + else: + return cls diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/train_main.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/train_main.py new file mode 100644 index 0000000..7f3be7d --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/train_main.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-4 上午9:59 +# @Author : zhuying +# @Company : Minivision +# @File : train_main.py +# @Software : PyCharm + +import torch +from torch import optim +from torch.nn import CrossEntropyLoss, MSELoss +from tqdm import tqdm +from tensorboardX import SummaryWriter + +from src.utility import get_time +from src.model_lib.MultiFTNet import MultiFTNet +from src.data_io.dataset_loader import get_train_loader + + +class TrainMain: + def __init__(self, conf): + self.conf = conf + self.board_loss_every = conf.board_loss_every + self.save_every = conf.save_every + self.step = 0 + self.start_epoch = 0 + self.train_loader = get_train_loader(self.conf) + + def train_model(self): + self._init_model_param() + self._train_stage() + + def _init_model_param(self): + self.cls_criterion = CrossEntropyLoss() + self.ft_criterion = MSELoss() + self.model = self._define_network() + self.optimizer = optim.SGD(self.model.module.parameters(), + lr=self.conf.lr, + weight_decay=5e-4, + momentum=self.conf.momentum) + + self.schedule_lr = optim.lr_scheduler.MultiStepLR( + self.optimizer, self.conf.milestones, self.conf.gamma, - 1) + + print("lr: ", self.conf.lr) + print("epochs: ", self.conf.epochs) + print("milestones: ", self.conf.milestones) + + def _train_stage(self): + self.model.train() + running_loss = 0. + running_acc = 0. + running_loss_cls = 0. + running_loss_ft = 0. + is_first = True + for e in range(self.start_epoch, self.conf.epochs): + if is_first: + self.writer = SummaryWriter(self.conf.log_path) + is_first = False + print('epoch {} started'.format(e)) + print("lr: ", self.schedule_lr.get_lr()) + + for sample, ft_sample, target in tqdm(iter(self.train_loader)): + imgs = [sample, ft_sample] + labels = target + + loss, acc, loss_cls, loss_ft = self._train_batch_data(imgs, labels) + running_loss_cls += loss_cls + running_loss_ft += loss_ft + running_loss += loss + running_acc += acc + + self.step += 1 + + if self.step % self.board_loss_every == 0 and self.step != 0: + loss_board = running_loss / self.board_loss_every + self.writer.add_scalar( + 'Training/Loss', loss_board, self.step) + acc_board = running_acc / self.board_loss_every + self.writer.add_scalar( + 'Training/Acc', acc_board, self.step) + lr = self.optimizer.param_groups[0]['lr'] + self.writer.add_scalar( + 'Training/Learning_rate', lr, self.step) + loss_cls_board = running_loss_cls / self.board_loss_every + self.writer.add_scalar( + 'Training/Loss_cls', loss_cls_board, self.step) + loss_ft_board = running_loss_ft / self.board_loss_every + self.writer.add_scalar( + 'Training/Loss_ft', loss_ft_board, self.step) + + running_loss = 0. + running_acc = 0. + running_loss_cls = 0. + running_loss_ft = 0. + if self.step % self.save_every == 0 and self.step != 0: + time_stamp = get_time() + self._save_state(time_stamp, extra=self.conf.job_name) + self.schedule_lr.step() + + time_stamp = get_time() + self._save_state(time_stamp, extra=self.conf.job_name) + self.writer.close() + + def _train_batch_data(self, imgs, labels): + self.optimizer.zero_grad() + labels = labels.to(self.conf.device) + embeddings, feature_map = self.model.forward(imgs[0].to(self.conf.device)) + + loss_cls = self.cls_criterion(embeddings, labels) + loss_fea = self.ft_criterion(feature_map, imgs[1].to(self.conf.device)) + + loss = 0.5*loss_cls + 0.5*loss_fea + acc = self._get_accuracy(embeddings, labels)[0] + loss.backward() + self.optimizer.step() + return loss.item(), acc, loss_cls.item(), loss_fea.item() + + def _define_network(self): + param = { + 'num_classes': self.conf.num_classes, + 'img_channel': self.conf.input_channel, + 'embedding_size': self.conf.embedding_size, + 'conv6_kernel': self.conf.kernel_size} + + model = MultiFTNet(**param).to(self.conf.device) + model = torch.nn.DataParallel(model, self.conf.devices) + model.to(self.conf.device) + return model + + def _get_accuracy(self, output, target, topk=(1,)): + maxk = max(topk) + batch_size = target.size(0) + _, pred = output.topk(maxk, 1, True, True) + pred = pred.t() + correct = pred.eq(target.view(1, -1).expand_as(pred)) + + ret = [] + for k in topk: + correct_k = correct[:k].view(-1).float().sum(dim=0, keepdim=True) + ret.append(correct_k.mul_(1. / batch_size)) + return ret + + def _save_state(self, time_stamp, extra=None): + save_path = self.conf.model_path + torch.save(self.model.state_dict(), save_path + '/' + + ('{}_{}_model_iter-{}.pth'.format(time_stamp, extra, self.step))) diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/src/utility.py b/facerecognition_presensi/SilentFaceAntiSpoofing/src/utility.py new file mode 100644 index 0000000..5615498 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/src/utility.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-4 下午2:13 +# @Author : zhuying +# @Company : Minivision +# @File : utility.py +# @Software : PyCharm + +from datetime import datetime +import os + + +def get_time(): + return (str(datetime.now())[:-10]).replace(' ', '-').replace(':', '-') + + +def get_kernel(height, width): + kernel_size = ((height + 15) // 16, (width + 15) // 16) + return kernel_size + + +def get_width_height(patch_info): + w_input = int(patch_info.split('x')[-1]) + h_input = int(patch_info.split('x')[0].split('_')[-1]) + return w_input,h_input + + +def parse_model_name(model_name): + info = model_name.split('_')[0:-1] + h_input, w_input = info[-1].split('x') + model_type = model_name.split('.pth')[0].split('_')[-1] + + if info[0] == "org": + scale = None + else: + scale = float(info[0]) + return int(h_input), int(w_input), model_type, scale + + +def make_if_not_exist(folder_path): + if not os.path.exists(folder_path): + os.makedirs(folder_path) diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/test.py b/facerecognition_presensi/SilentFaceAntiSpoofing/test.py new file mode 100644 index 0000000..5c48eb1 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/test.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-9 下午3:06 +# @Author : zhuying +# @Company : Minivision +# @File : test.py +# @Software : PyCharm + +import os +import cv2 +import numpy as np +import argparse +import warnings +import time + +from src.anti_spoof_predict import AntiSpoofPredict +from src.generate_patches import CropImage +from src.utility import parse_model_name +warnings.filterwarnings('ignore') + + +SAMPLE_IMAGE_PATH = "./images/sample/" + + +# 因为安卓端APK获取的视频流宽高比为3:4,为了与之一致,所以将宽高比限制为3:4 +def check_image(image): + height, width, channel = image.shape + if width/height != 3/4: + print("Image is not appropriate!!!\nHeight/Width should be 4/3.") + return False + else: + return True + + +def test(image_name, model_dir, device_id): + model_test = AntiSpoofPredict(device_id) + image_cropper = CropImage() + image = cv2.imread(SAMPLE_IMAGE_PATH + image_name) + result = check_image(image) + if result is False: + return + image_bbox = model_test.get_bbox(image) + prediction = np.zeros((1, 3)) + test_speed = 0 + # sum the prediction from single model's result + for model_name in os.listdir(model_dir): + h_input, w_input, model_type, scale = parse_model_name(model_name) + param = { + "org_img": image, + "bbox": image_bbox, + "scale": scale, + "out_w": w_input, + "out_h": h_input, + "crop": True, + } + if scale is None: + param["crop"] = False + img = image_cropper.crop(**param) + start = time.time() + prediction += model_test.predict(img, os.path.join(model_dir, model_name)) + test_speed += time.time()-start + + # draw result of prediction + label = np.argmax(prediction) + value = prediction[0][label]/2 + if label == 1: + print("Image '{}' is Real Face. Score: {:.2f}.".format(image_name, value)) + result_text = "RealFace Score: {:.2f}".format(value) + color = (255, 0, 0) + else: + print("Image '{}' is Fake Face. Score: {:.2f}.".format(image_name, value)) + result_text = "FakeFace Score: {:.2f}".format(value) + color = (0, 0, 255) + print("Prediction cost {:.2f} s".format(test_speed)) + cv2.rectangle( + image, + (image_bbox[0], image_bbox[1]), + (image_bbox[0] + image_bbox[2], image_bbox[1] + image_bbox[3]), + color, 2) + cv2.putText( + image, + result_text, + (image_bbox[0], image_bbox[1] - 5), + cv2.FONT_HERSHEY_COMPLEX, 0.5*image.shape[0]/1024, color) + + format_ = os.path.splitext(image_name)[-1] + result_image_name = image_name.replace(format_, "_result" + format_) + cv2.imwrite(SAMPLE_IMAGE_PATH + result_image_name, image) + + +if __name__ == "__main__": + desc = "test" + parser = argparse.ArgumentParser(description=desc) + parser.add_argument( + "--device_id", + type=int, + default=0, + help="which gpu id, [0/1/2/3]") + parser.add_argument( + "--model_dir", + type=str, + default="./resources/anti_spoof_models", + help="model_lib used to test") + parser.add_argument( + "--image_name", + type=str, + default="image_F1.jpg", + help="image used to test") + args = parser.parse_args() + test(args.image_name, args.model_dir, args.device_id) diff --git a/facerecognition_presensi/SilentFaceAntiSpoofing/train.py b/facerecognition_presensi/SilentFaceAntiSpoofing/train.py new file mode 100644 index 0000000..58fc277 --- /dev/null +++ b/facerecognition_presensi/SilentFaceAntiSpoofing/train.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# @Time : 20-6-3 下午5:39 +# @Author : zhuying +# @Company : Minivision +# @File : train.py +# @Software : PyCharm + +import argparse +import os +from src.train_main import TrainMain +from src.default_config import get_default_config, update_config + + +def parse_args(): + """parsing and configuration""" + desc = "Silence-FAS" + parser = argparse.ArgumentParser(description=desc) + parser.add_argument("--device_ids", type=str, default="1", help="which gpu id, 0123") + parser.add_argument("--patch_info", type=str, default="1_80x80", + help="[org_1_80x60 / 1_80x80 / 2.7_80x80 / 4_80x80]") + args = parser.parse_args() + cuda_devices = [int(elem) for elem in args.device_ids] + os.environ["CUDA_VISIBLE_DEVICES"] = ','.join(map(str, cuda_devices)) + args.devices = [x for x in range(len(cuda_devices))] + return args + + +if __name__ == "__main__": + args = parse_args() + conf = get_default_config() + conf = update_config(args, conf) + trainer = TrainMain(conf) + trainer.train_model() + diff --git a/facerecognition_presensi/Website/.env.example b/facerecognition_presensi/Website/.env.example new file mode 100644 index 0000000..a8a8970 --- /dev/null +++ b/facerecognition_presensi/Website/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8000/api/v1 diff --git a/facerecognition_presensi/Website/eslint.config.js b/facerecognition_presensi/Website/eslint.config.js new file mode 100644 index 0000000..cee1e2c --- /dev/null +++ b/facerecognition_presensi/Website/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/facerecognition_presensi/Website/index.html b/facerecognition_presensi/Website/index.html new file mode 100644 index 0000000..0b5c289 --- /dev/null +++ b/facerecognition_presensi/Website/index.html @@ -0,0 +1,13 @@ + + + + + + + E-learn - Platform Pembelajaran Online + + +
+ + + diff --git a/facerecognition_presensi/Website/package-lock.json b/facerecognition_presensi/Website/package-lock.json new file mode 100644 index 0000000..0fd83be --- /dev/null +++ b/facerecognition_presensi/Website/package-lock.json @@ -0,0 +1,4560 @@ +{ + "name": "elearnweb", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elearnweb", + "version": "0.0.0", + "dependencies": { + "axios": "^1.13.2", + "leaflet": "^1.9.4", + "lucide-react": "^0.542.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-leaflet": "^5.0.0", + "react-router-dom": "^7.8.2", + "sweetalert2": "^11.26.25", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "vite": "^7.1.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.8.0.tgz", + "integrity": "sha512-MJQFqrZgcW0UNYLGOuQpey/oTN59vyWwplvCGZztn1cKz9agZPPYpJB7h2OMmuu7VLqkvEjN8feFZJmxNF9D+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.34", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", + "integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.0.tgz", + "integrity": "sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.0.tgz", + "integrity": "sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.0.tgz", + "integrity": "sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.0.tgz", + "integrity": "sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.0.tgz", + "integrity": "sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.0.tgz", + "integrity": "sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.0.tgz", + "integrity": "sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.0.tgz", + "integrity": "sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.0.tgz", + "integrity": "sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.0.tgz", + "integrity": "sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.0.tgz", + "integrity": "sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.0.tgz", + "integrity": "sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.0.tgz", + "integrity": "sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.0.tgz", + "integrity": "sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.0.tgz", + "integrity": "sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.0.tgz", + "integrity": "sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.0.tgz", + "integrity": "sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.0.tgz", + "integrity": "sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.0.tgz", + "integrity": "sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.0.tgz", + "integrity": "sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.0.tgz", + "integrity": "sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", + "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz", + "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.3", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.34", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.214", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.214.tgz", + "integrity": "sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.542.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", + "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", + "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz", + "integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==", + "license": "MIT", + "dependencies": { + "react-router": "7.8.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.0.tgz", + "integrity": "sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.50.0", + "@rollup/rollup-android-arm64": "4.50.0", + "@rollup/rollup-darwin-arm64": "4.50.0", + "@rollup/rollup-darwin-x64": "4.50.0", + "@rollup/rollup-freebsd-arm64": "4.50.0", + "@rollup/rollup-freebsd-x64": "4.50.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.0", + "@rollup/rollup-linux-arm-musleabihf": "4.50.0", + "@rollup/rollup-linux-arm64-gnu": "4.50.0", + "@rollup/rollup-linux-arm64-musl": "4.50.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.50.0", + "@rollup/rollup-linux-ppc64-gnu": "4.50.0", + "@rollup/rollup-linux-riscv64-gnu": "4.50.0", + "@rollup/rollup-linux-riscv64-musl": "4.50.0", + "@rollup/rollup-linux-s390x-gnu": "4.50.0", + "@rollup/rollup-linux-x64-gnu": "4.50.0", + "@rollup/rollup-linux-x64-musl": "4.50.0", + "@rollup/rollup-openharmony-arm64": "4.50.0", + "@rollup/rollup-win32-arm64-msvc": "4.50.0", + "@rollup/rollup-win32-ia32-msvc": "4.50.0", + "@rollup/rollup-win32-x64-msvc": "4.50.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sweetalert2": { + "version": "11.26.25", + "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.26.25.tgz", + "integrity": "sha512-+hunCOJdJ6FLj04T9YSLvvZXRjsvIkTeTKP2e4VF8CaBias961BTnWiSFAy7F/CM5eq3QK2Rraoc5Gzftslvkg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/limonte" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.4.tgz", + "integrity": "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/facerecognition_presensi/Website/package.json b/facerecognition_presensi/Website/package.json new file mode 100644 index 0000000..a45ab4a --- /dev/null +++ b/facerecognition_presensi/Website/package.json @@ -0,0 +1,37 @@ +{ + "name": "elearnweb", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.13.2", + "leaflet": "^1.9.4", + "lucide-react": "^0.542.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-leaflet": "^5.0.0", + "react-router-dom": "^7.8.2", + "sweetalert2": "^11.26.25", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "vite": "^7.1.2" + } +} diff --git a/facerecognition_presensi/Website/postcss.config.js b/facerecognition_presensi/Website/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/facerecognition_presensi/Website/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/facerecognition_presensi/Website/public/logo.png b/facerecognition_presensi/Website/public/logo.png new file mode 100644 index 0000000..094444f Binary files /dev/null and b/facerecognition_presensi/Website/public/logo.png differ diff --git a/facerecognition_presensi/Website/public/vite.svg b/facerecognition_presensi/Website/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/facerecognition_presensi/Website/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/facerecognition_presensi/Website/src/App.css b/facerecognition_presensi/Website/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/facerecognition_presensi/Website/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/facerecognition_presensi/Website/src/App.jsx b/facerecognition_presensi/Website/src/App.jsx new file mode 100644 index 0000000..0bf1951 --- /dev/null +++ b/facerecognition_presensi/Website/src/App.jsx @@ -0,0 +1,114 @@ +// src/App.jsx +import { Routes, Route, Navigate } from "react-router-dom"; +import LoginPage from "./pages/Login"; +import Dashboard from "./pages/dashboard"; +import Presensi from "./pages/presensi"; +import JadwalKuliah from "./pages/jadwalKuliah"; +import UserPage from "./pages/user"; +import KelolaDosen from "./pages/kelolaDosen"; +import DetailProfilDosen from "./pages/detailProfilDosen"; +import ProfilSaya from "./pages/profilSaya"; +import KelolaMataKuliah from "./pages/kelolaMataKuliah"; +import KelolaKelas from "./pages/kelolaKelas"; +import KelolaMasterData from "./pages/kelolaMasterData"; +import KelolaPenawaran from "./pages/kelolaPenawaran"; +import DetailPresensi from "./pages/detailPresensi"; +import KelolaMahasiswa from "./pages/kelolaMahasiswa"; +import KelolaGeolokasi from "./pages/kelolaGeolokasi"; +import ProtectedRoute from "./components/ProtectedRoute"; + +export default function App() { + const protectedRoutes = [ + { path: "/dashboard", element: }, + { path: "/presensi", element: }, + { path: "/presensi/detail/:id_kelas_mk", element: }, + { path: "/jadwal-kuliah", element: }, + { + path: "/kelola-mata-kuliah", + element: , + roles: ["admin"], + }, + { path: "/kelola-kelas", element: , roles: ["admin"] }, + { + path: "/penawaran-matakuliah", + element: , + roles: ["admin"], + }, + { path: "/kelola-dosen", element: , roles: ["admin"] }, + { + path: "/kelola-mahasiswa", + element: , + roles: ["admin"], + }, + { + path: "/kelola-master-data", + element: , + roles: ["admin"], + }, + { + path: "/detail-profil-dosen/:id_dosen", + element: , + roles: ["admin"], + }, + { + path: "/profil-saya", + element: , + roles: ["admin", "dosen"], + }, + { + path: "/kelola-geolokasi", + element: , + roles: ["admin"], + }, + { path: "/user", element: , roles: ["admin"] }, + ]; + + return ( + + } /> + + } /> + + {protectedRoutes.map((route) => ( + + {route.element} + + } + /> + ))} + + +
+
+

404

+
+ Oops! +
+
+

+ Halaman tidak ditemukan +

+

+ Halaman yang Anda cari mungkin telah dihapus, namanya berubah, + atau sementara tidak tersedia. +

+ + Kembali ke Dashboard + +
+ + } + /> +
+ ); +} diff --git a/facerecognition_presensi/Website/src/E-Learn Application Logo - Modern Emblem.png b/facerecognition_presensi/Website/src/E-Learn Application Logo - Modern Emblem.png new file mode 100644 index 0000000..094444f Binary files /dev/null and b/facerecognition_presensi/Website/src/E-Learn Application Logo - Modern Emblem.png differ diff --git a/facerecognition_presensi/Website/src/assets/react.svg b/facerecognition_presensi/Website/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/facerecognition_presensi/Website/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/facerecognition_presensi/Website/src/components/Header.css b/facerecognition_presensi/Website/src/components/Header.css new file mode 100644 index 0000000..0b6e42b --- /dev/null +++ b/facerecognition_presensi/Website/src/components/Header.css @@ -0,0 +1,243 @@ +/* Header Wrapper */ +.header { + background-color: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(229, 231, 235, 0.5); + position: sticky; + top: 0; + z-index: 50; +} + +/* Container */ +.container { + max-width: 80rem; + margin: 0 auto; + padding: 0 1rem; +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0; +} + +/* Logo Section */ +.logo-section { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.logo-icon { + width: 2.5rem; + height: 2.5rem; + background-color: #2563eb; + border-radius: 0.75rem; + display: flex; + align-items: center; + justify-content: center; +} + +.icon-white { + width: 1.5rem; + height: 1.5rem; + color: white; +} + +.logo-title { + font-size: 1.25rem; + font-weight: 700; + color: #111827; +} + +.logo-subtitle { + font-size: 0.875rem; + color: #6b7280; +} + +/* Search Section */ +.search-section { + flex: 1; + max-width: 28rem; + margin: 0 2rem; + display: none; +} + +@media (min-width: 768px) { + .search-section { + display: flex; + } +} + +.search-wrapper { + position: relative; + width: 100%; +} + +.search-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; + width: 1.25rem; + height: 1.25rem; +} + +.search-input { + width: 100%; + padding: 0.5rem 1rem 0.5rem 2.5rem; + border: 1px solid #d1d5db; + border-radius: 0.75rem; + background-color: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(4px); +} + +.search-input:focus { + outline: none; + border-color: transparent; + box-shadow: 0 0 0 2px #3b82f6; +} + +/* Profile Section */ +.profile-section { + display: flex; + align-items: center; + gap: 1rem; +} + +.notif-btn { + position: relative; + padding: 0.5rem; + color: #9ca3af; + transition: color 0.2s; +} + +.notif-btn:hover { + color: #4b5563; +} + +.notif-icon { + width: 1.5rem; + height: 1.5rem; +} + +.notif-badge { + position: absolute; + top: -0.25rem; + right: -0.25rem; + width: 1rem; + height: 1rem; + background-color: #ef4444; + color: white; + border-radius: 9999px; + font-size: 0.75rem; + display: flex; + align-items: center; + justify-content: center; +} + +/* Profile Dropdown */ +.profile-wrapper { + position: relative; +} + +.profile-btn { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border-radius: 0.75rem; + transition: background-color 0.2s; +} + +.profile-btn:hover { + background-color: rgba(243, 244, 246, 0.5); +} + +.profile-avatar { + width: 2rem; + height: 2rem; + background: linear-gradient(to right, #3b82f6, #9333ea); + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; +} + +.profile-initials { + font-size: 0.875rem; + font-weight: 500; + color: white; +} + +.profile-info { + display: none; +} + +@media (min-width: 768px) { + .profile-info { + display: block; + } +} + +.profile-name { + font-size: 0.875rem; + font-weight: 500; + color: #111827; +} + +.profile-role { + font-size: 0.75rem; + color: #6b7280; +} + +.chevron { + width: 1rem; + height: 1rem; + color: #9ca3af; +} + +/* Dropdown */ +.dropdown { + position: absolute; + right: 0; + margin-top: 0.5rem; + width: 12rem; + background-color: white; + border: 1px solid #e5e7eb; + border-radius: 0.75rem; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.05); + padding: 0.25rem 0; + z-index: 50; +} + +.dropdown-item { + display: flex; + align-items: center; + padding: 0.5rem 1rem; + font-size: 0.875rem; + color: #374151; + transition: background-color 0.2s; +} + +.dropdown-item:hover { + background-color: #f3f4f6; +} + +.dropdown-icon { + width: 1rem; + height: 1rem; + margin-right: 0.75rem; +} + +.dropdown-divider { + border: none; + border-top: 1px solid #e5e7eb; + margin: 0.25rem 0; +} + +.logout { + color: #dc2626; +} diff --git a/facerecognition_presensi/Website/src/components/ProtectedRoute.jsx b/facerecognition_presensi/Website/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..b0bb137 --- /dev/null +++ b/facerecognition_presensi/Website/src/components/ProtectedRoute.jsx @@ -0,0 +1,31 @@ +// src/components/ProtectedRoute.jsx +import { Navigate } from "react-router-dom"; +import { getToken, getUser, isTokenExpired, clearAuth } from "../utils/auth"; + +/** + * Backend roles: admin | dosen | mahasiswa + * allowedRoles: array of backend role strings that can access the route + */ +const ProtectedRoute = ({ children, allowedRoles }) => { + const token = getToken(); + + // If no token or token is expired, clear session and redirect to login + if (!token || isTokenExpired()) { + clearAuth(); + return ; + } + + // If allowedRoles specified, check role + if (allowedRoles && allowedRoles.length > 0) { + const user = getUser(); + const role = user?.role; // "admin" | "dosen" | "mahasiswa" + + if (!allowedRoles.includes(role)) { + return ; + } + } + + return children; +}; + +export default ProtectedRoute; diff --git a/facerecognition_presensi/Website/src/components/Sidebar.css b/facerecognition_presensi/Website/src/components/Sidebar.css new file mode 100644 index 0000000..c80cd71 --- /dev/null +++ b/facerecognition_presensi/Website/src/components/Sidebar.css @@ -0,0 +1,72 @@ +.sidebar { + background-color: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(12px); + border: 1px solid rgba(229, 231, 235, 0.5); + border-radius: 1rem; + padding: 0.875rem; + position: sticky; + top: 6rem; + width: 100%; +} + +.sidebar-title { + font-size: 0.875rem; + font-weight: 600; + color: #111827; + margin-bottom: 1rem; + text-align: left; + padding-left: 0.25rem; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +/* Tombol menu */ +.sidebar-item { + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + border-radius: 0.625rem; + text-align: left; + transition: all 0.2s ease; + font-size: 0.813rem; + font-weight: 500; +} + +/* Saat aktif */ +.sidebar-item.active { + background-color: #2563eb; + color: white; + box-shadow: 0 0 10px rgba(37, 99, 235, 0.25); +} + +/* Saat tidak aktif */ +.sidebar-item.inactive { + color: #4b5563; +} + +.sidebar-item.inactive:hover { + background-color: rgba(243, 244, 246, 0.5); + color: #111827; +} + +/* Ikon */ +.sidebar-icon { + width: 1.125rem; + height: 1.125rem; + flex-shrink: 0; +} + +/* Label */ +.sidebar-label { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} \ No newline at end of file diff --git a/facerecognition_presensi/Website/src/components/header.jsx b/facerecognition_presensi/Website/src/components/header.jsx new file mode 100644 index 0000000..b9f7db9 --- /dev/null +++ b/facerecognition_presensi/Website/src/components/header.jsx @@ -0,0 +1,134 @@ +import { + BookOpen, + ChevronDown, + LogOut, + Menu, + X, +} from "lucide-react"; +import { useState, useRef, useEffect } from "react"; +import { getUser } from "../utils/auth"; + +export default function Header({ + onLogout, + onToggleMobileSidebar, + isMobileSidebarOpen, +}) { + const [showProfile, setShowProfile] = useState(false); + const [userData, setUserData] = useState(null); + const dropdownRef = useRef(null); + + // Load user data from storage + useEffect(() => { + const user = getUser(); + if (user) { + setUserData(user); + } + }, []); + + // Tutup dropdown kalau klik di luar + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setShowProfile(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+
+
+ {/* Left Side: Hamburger + Logo */} +
+ {/* Hamburger Menu untuk Mobile */} + + + {/* Logo */} +
+ E-learn Logo +
+
+

+ E-learn +

+

+ Platform Pembelajaran Online +

+
+
+ + {/* Right Side: User Profile */} +
+ {/* Profil User */} +
+ + + {/* Dropdown Menu */} + {showProfile && ( +
+ +
+ )} +
+
+
+
+
+ ); +} diff --git a/facerecognition_presensi/Website/src/components/sidebar.jsx b/facerecognition_presensi/Website/src/components/sidebar.jsx new file mode 100644 index 0000000..6b28dff --- /dev/null +++ b/facerecognition_presensi/Website/src/components/sidebar.jsx @@ -0,0 +1,89 @@ +// src/components/sidebar.jsx +import "./Sidebar.css"; +import { useNavigate } from "react-router-dom"; +import { getRole } from "../utils/auth"; + +export default function Sidebar({ + navigationItems, + activeNav, + setActiveNav, + onCloseMobile, +}) { + const navigate = useNavigate(); + + const routes = { + Dashboard: "/dashboard", + "Assignment-Kelas": "/mata-kuliah", + "jadwal-kuliah": "/jadwal-kuliah", + presensi: "/presensi", + materi: "/materi", + informasi: "/informasi", + "profil-saya": "/profil-saya", + "kelola-mata-kuliah": "/kelola-mata-kuliah", + "kelola-kelas": "/kelola-kelas", + "kelola-dosen": "/kelola-dosen", + "kelola-mahasiswa": "/kelola-mahasiswa", + "kelola-master-data": "/kelola-master-data", + "kelola-geolokasi": "/kelola-geolokasi", + "penawaran-matakuliah": "/penawaran-matakuliah", + User: "/user", + }; + + const handleNavigation = (item) => { + setActiveNav(item.id); + + // Tutup mobile sidebar saat navigasi + if (onCloseMobile) { + onCloseMobile(); + } + + if (routes[item.id]) { + navigate(routes[item.id]); + } + }; + + const userRole = getRole(); + // Backend roles: "admin" | "dosen" | "mahasiswa" + + return ( +
+

Menu Utama

+ +
+ ); +} diff --git a/facerecognition_presensi/Website/src/hooks/usePolling.js b/facerecognition_presensi/Website/src/hooks/usePolling.js new file mode 100644 index 0000000..4d40649 --- /dev/null +++ b/facerecognition_presensi/Website/src/hooks/usePolling.js @@ -0,0 +1,20 @@ +import { useEffect, useRef } from 'react'; + +export const usePolling = (callback, interval = 5000, dependencies = []) => { + const savedCallback = useRef(); + + // Remember the latest callback. + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // Set up the interval. + useEffect(() => { + function tick() { + savedCallback.current(); + } + // Set up the interval + let id = setInterval(tick, interval); + return () => clearInterval(id); + }, [interval, ...dependencies]); +}; diff --git a/facerecognition_presensi/Website/src/index.css b/facerecognition_presensi/Website/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/facerecognition_presensi/Website/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/facerecognition_presensi/Website/src/layouts/dashboardlayout.jsx b/facerecognition_presensi/Website/src/layouts/dashboardlayout.jsx new file mode 100644 index 0000000..a063bb2 --- /dev/null +++ b/facerecognition_presensi/Website/src/layouts/dashboardlayout.jsx @@ -0,0 +1,81 @@ +import Header from "../components/header"; +import Sidebar from "../components/sidebar"; +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { clearAuth } from "../utils/auth"; + +export default function DashboardLayout({ + children, + navigationItems, + activeNav, + setActiveNav, + onLogout, +}) { + const navigate = useNavigate(); + const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); + + const handleLogout = () => { + if (onLogout) { + onLogout(); + } else { + clearAuth(); + window.location.href = "/login"; + } + }; + + const toggleMobileSidebar = () => { + setIsMobileSidebarOpen(!isMobileSidebarOpen); + }; + + const closeMobileSidebar = () => { + setIsMobileSidebarOpen(false); + }; + + return ( +
+ {/* Header menerima onLogout dan mobile sidebar controls */} +
+ +
+
+ {/* Mobile Sidebar Overlay */} + {isMobileSidebarOpen && ( +
+ )} + + {/* Sidebar - Fixed width, lebih kecil */} +
+ +
+ + {/* Content Area - Flexible, mengambil sisa space */} +
{children}
+
+
+
+ ); +} diff --git a/facerecognition_presensi/Website/src/main.jsx b/facerecognition_presensi/Website/src/main.jsx new file mode 100644 index 0000000..d719969 --- /dev/null +++ b/facerecognition_presensi/Website/src/main.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + +); diff --git a/facerecognition_presensi/Website/src/navigation/navigation.jsx b/facerecognition_presensi/Website/src/navigation/navigation.jsx new file mode 100644 index 0000000..8bd321d --- /dev/null +++ b/facerecognition_presensi/Website/src/navigation/navigation.jsx @@ -0,0 +1,64 @@ +import { + LayoutDashboard, + Calendar, + CheckCircle, + BookOpen, + CalendarDays, + User, + Users, + School, + Layers, + ClipboardList, + GraduationCap, + MapPin, +} from "lucide-react"; + +export const navigationItems = [ + { id: "Dashboard", label: "Dashboard", icon: LayoutDashboard }, + { id: "jadwal-kuliah", label: "Jadwal Kuliah", icon: CalendarDays }, + { id: "presensi", label: "Input Presensi", icon: CheckCircle }, + { id: "profil-saya", label: "Profil Saya", icon: User, adminOnly: true }, + { + id: "kelola-mata-kuliah", + label: "Kelola Mata Kuliah", + icon: BookOpen, + superAdminOnly: true, + }, + { + id: "penawaran-matakuliah", + label: "Penawaran Mata Kuliah", + icon: ClipboardList, + superAdminOnly: true, + }, + { + id: "kelola-kelas", + label: "Kelola Kelas", + icon: School, + superAdminOnly: true, + }, + { + id: "kelola-dosen", + label: "Kelola Dosen", + icon: Users, + superAdminOnly: true, + }, + { + id: "kelola-mahasiswa", + label: "Kelola Mahasiswa", + icon: GraduationCap, + superAdminOnly: true, + }, + { + id: "kelola-master-data", + label: "Master Data", + icon: Layers, + superAdminOnly: true, + }, + { + id: "kelola-geolokasi", + label: "Kelola Geolokasi", + icon: MapPin, + superAdminOnly: true, + }, + { id: "User", label: "Manajemen User", icon: User, superAdminOnly: true }, +]; diff --git a/facerecognition_presensi/Website/src/pages/Dashboard.css b/facerecognition_presensi/Website/src/pages/Dashboard.css new file mode 100644 index 0000000..66dcce0 --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/Dashboard.css @@ -0,0 +1,833 @@ +/* ================================================ + E-LEARNING DASHBOARD — MODERN PROFESSIONAL EDITION + Design System: Clean, Solid Surfaces, Refined Detail + ================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); + +/* ---------- Design Tokens ---------- */ +:root { + /* Base palette — unchanged */ + --primary-50: #eff6ff; + --primary-100: #dbeafe; + --primary-500: #3b82f6; + --primary-600: #2563eb; + --primary-700: #1d4ed8; + --primary-900: #1e3a8a; + + --success-50: #f0fdf4; + --success-500: #22c55e; + --success-600: #16a34a; + --success-700: #15803d; + + --danger-50: #fef2f2; + --danger-500: #ef4444; + --danger-600: #dc2626; + --danger-700: #b91c1c; + + --warning-50: #fffbeb; + --warning-500: #f59e0b; + --warning-600: #d97706; + + --purple-50: #faf5ff; + --purple-500: #8b5cf6; + --purple-600: #7c3aed; + + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-300: #d1d5db; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-600: #4b5563; + --gray-700: #374151; + --gray-800: #1f2937; + --gray-900: #111827; + + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 14px; + --radius-full: 9999px; + + --shadow-sm: 0 1px 2px rgba(17, 24, 39, 0.05); + --shadow-md: 0 1px 3px rgba(17, 24, 39, 0.07), 0 4px 12px rgba(17, 24, 39, 0.05); + --shadow-lg: 0 4px 6px rgba(17, 24, 39, 0.05), 0 12px 24px rgba(17, 24, 39, 0.08); + + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + + --container-padding: 1.5rem; + --card-gap: 1.25rem; + + /* Professional accents */ + --surface: #ffffff; + --page-bg: #f7f8fa; + --border: #e8eaee; +} + +/* ---------- Loading Screen ---------- */ +.loading-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--page-bg); +} + +.loading-content { + text-align: center; +} + +.loading-spinner { + width: 3.5rem; + height: 3.5rem; + margin: 0 auto 1rem; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--gray-200); + border-top-color: var(--primary-600); + border-radius: var(--radius-full); + animation: spin 0.8s linear infinite; +} + +.spinner-icon { + display: none; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-text { + color: var(--gray-500); + font-weight: 500; + font-size: 0.875rem; + letter-spacing: 0.01em; +} + +/* ---------- Container ---------- */ +.dashboard-container { + display: flex; + flex-direction: column; + gap: var(--card-gap); + padding: var(--container-padding); + max-width: 1400px; + margin: 0 auto; +} + +/* ---------- Welcome / Hero Section ---------- */ +.welcome-section { + position: relative; + background: linear-gradient(120deg, var(--primary-900) 0%, var(--primary-700) 55%, var(--primary-600) 100%); + border-radius: var(--radius-xl); + padding: 1.75rem 2rem; + color: #fff; + overflow: hidden; + box-shadow: var(--shadow-md); +} + +/* Subtle technical grid pattern instead of cartoonish circles */ +.welcome-section::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); + background-size: 28px 28px; + pointer-events: none; +} + +/* Fine top highlight line */ +.welcome-section::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent); +} + +.welcome-content { + position: relative; + z-index: 2; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.5rem; +} + +.greeting-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + padding: 0.3125rem 0.75rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 500; + margin-bottom: 0.75rem; + width: fit-content; + color: rgba(255, 255, 255, 0.9); +} + +.greeting-dot { + width: 6px; + height: 6px; + background: var(--success-500); + border-radius: var(--radius-full); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.25); +} + +.welcome-title { + font-size: 1.625rem; + font-weight: 700; + margin-bottom: 0.375rem; + line-height: 1.25; + letter-spacing: -0.01em; +} + +.welcome-subtitle { + color: rgba(255, 255, 255, 0.65); + font-size: 0.9375rem; + font-weight: 400; +} + +.welcome-icon { + display: none; +} + +@media (min-width: 768px) { + .welcome-icon { display: block; } +} + +.welcome-icon-circle { + width: 4rem; + height: 4rem; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; +} + +.welcome-icon-img { + width: 1.75rem; + height: 1.75rem; + color: rgba(255, 255, 255, 0.9); +} + +.welcome-decoration { + display: none; /* removed — replaced by grid pattern */ +} + +/* ---------- Stats Grid ---------- */ +.stats-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--card-gap); +} + +@media (min-width: 640px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (min-width: 1024px) { + .stats-grid { grid-template-columns: repeat(4, 1fr); } +} + +.stat-card { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast); + overflow: hidden; + cursor: pointer; +} + +/* Left accent edge — replaces cartoonish top bar */ +.stat-card::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + opacity: 1; +} + +.stat-card-blue::before { background: var(--primary-500); } +.stat-card-green::before { background: var(--success-500); } +.stat-card-orange::before { background: var(--warning-500); } +.stat-card-purple::before { background: var(--purple-500); } + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + border-color: var(--gray-300); +} + +.stat-content { + display: flex; + justify-content: space-between; + align-items: flex-start; + position: relative; + z-index: 1; +} + +.stat-info { flex: 1; } + +.stat-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-500); + letter-spacing: 0.04em; +} + +.stat-value { + font-size: 1.875rem; + font-weight: 700; + color: var(--gray-900); + line-height: 1.2; + margin-top: 0.375rem; + letter-spacing: -0.02em; +} + +.stat-trend { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.5rem; + padding: 0.1875rem 0.5625rem; + border-radius: var(--radius-full); + font-size: 0.6875rem; + font-weight: 600; +} + +.stat-trend.trend-up { + background: var(--success-50); + color: var(--success-700); +} + +.stat-trend.trend-down { + background: var(--danger-50); + color: var(--danger-700); +} + +.trend-icon { + width: 0.75rem; + height: 0.75rem; +} + +.stat-icon-wrapper { + width: 2.5rem; + height: 2.5rem; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* Softer icon treatments */ +.stat-card-blue .stat-icon-wrapper { background: var(--primary-50); } +.stat-card-green .stat-icon-wrapper { background: var(--success-50); } +.stat-card-orange .stat-icon-wrapper { background: var(--warning-50); } +.stat-card-purple .stat-icon-wrapper { background: var(--purple-50); } + +.stat-icon-img { + width: 1.25rem; + height: 1.25rem; +} + +.stat-card-blue .stat-icon-img { color: var(--primary-600); } +.stat-card-green .stat-icon-img { color: var(--success-600); } +.stat-card-orange .stat-icon-img { color: var(--warning-600); } +.stat-card-purple .stat-icon-img { color: var(--purple-600); } + +/* Shine effect disabled for professional look */ +.stat-shine { display: none; } + +/* ---------- Bento Grid ---------- */ +.bento-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--card-gap); +} + +@media (min-width: 1024px) { + .bento-grid { grid-template-columns: 3fr 1fr; } +} + +.bento-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; + transition: box-shadow var(--transition-fast); +} + +.bento-card:hover { box-shadow: var(--shadow-md); } + +.bento-card-large { grid-column: span 1; } + +@media (min-width: 1024px) { + .bento-card-large { grid-row: span 2; } +} + +/* ---------- Card Header ---------- */ +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.25rem; + gap: 1rem; +} + +.card-header-left { + display: flex; + align-items: center; + gap: 0.875rem; +} + +.card-icon-wrapper { + width: 2.25rem; + height: 2.25rem; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; +} + +.card-icon-wrapper.card-icon-blue { background: var(--primary-50); color: var(--primary-600); } +.card-icon-wrapper.card-icon-purple { background: var(--purple-50); color: var(--purple-600); } +.card-icon-wrapper.card-icon-green { background: var(--success-50); color: var(--success-600); } + +.card-icon { + width: 1.125rem; + height: 1.125rem; +} + +.card-title { + font-size: 1rem; + font-weight: 600; + color: var(--gray-900); + margin: 0; + letter-spacing: -0.01em; +} + +.card-subtitle { + font-size: 0.8125rem; + color: var(--gray-500); + margin: 0.125rem 0 0; +} + +.card-action-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.875rem; + background: var(--surface); + border: 1px solid var(--gray-200); + border-radius: var(--radius-md); + color: var(--gray-600); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.card-action-btn:hover { + border-color: var(--primary-500); + color: var(--primary-600); + background: var(--primary-50); +} + +.btn-icon { + width: 0.875rem; + height: 0.875rem; +} + +/* ---------- Search Input ---------- */ +.dashboard-search { + display: flex; + align-items: center; + position: relative; +} + +.dashboard-search-input { + padding: 0.5rem 0.875rem 0.5rem 2.25rem; + border: 1px solid var(--gray-200); + border-radius: var(--radius-md); + font-size: 0.8125rem; + color: var(--gray-900); + background: var(--surface); + transition: all var(--transition-fast); + width: 240px; +} + +.dashboard-search-input:focus { + outline: none; + border-color: var(--primary-500); + box-shadow: 0 0 0 3px var(--primary-100); +} + +.dashboard-search-input::placeholder { + color: var(--gray-400); +} + +.dashboard-search-icon { + position: absolute; + left: 0.75rem; + width: 1rem; + height: 1rem; + color: var(--gray-400); + pointer-events: none; +} + +/* ---------- Data Table ---------- */ +.table-wrapper { + overflow-x: auto; + margin: 0 -1.5rem; + padding: 0 1.5rem; +} + +.data-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.875rem; +} + +.data-table th { + text-align: left; + padding: 0.625rem 1rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--gray-500); + text-transform: uppercase; + letter-spacing: 0.06em; + background: var(--gray-50); + border-bottom: 1px solid var(--border); + border-top: 1px solid var(--border); + white-space: nowrap; +} + +.data-table th:first-child { + border-left: 1px solid var(--border); + border-top-left-radius: var(--radius-md); + border-bottom-left-radius: var(--radius-md); +} + +.data-table th:last-child { + border-right: 1px solid var(--border); + border-top-right-radius: var(--radius-md); + border-bottom-right-radius: var(--radius-md); +} + +.table-row { + transition: background var(--transition-fast); +} + +.table-row:hover { background: var(--gray-50); } + +.data-table td { + padding: 0.875rem 1rem; + border-bottom: 1px solid var(--gray-100); + vertical-align: middle; +} + +.data-table tbody tr:last-child td { border-bottom: none; } + +.cell-primary { + font-weight: 500; + color: var(--gray-900); +} + +.text-center { text-align: center; } + +.text-muted { + color: var(--gray-500); + font-size: 0.8125rem; +} + +/* Badges — flatter, more corporate */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.1875rem 0.625rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; +} + +.badge-outline { + background: var(--gray-100); + border: 1px solid var(--gray-200); + color: var(--gray-600); +} + +.week-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + height: 1.75rem; + padding: 0 0.375rem; + background: var(--primary-50); + color: var(--primary-700); + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 0.75rem; +} + +.status-badge { + display: inline-flex; + align-items: center; + padding: 0.1875rem 0.625rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.status-success { + background: var(--success-50); + color: var(--success-700); +} + +.status-danger { + background: var(--danger-50); + color: var(--danger-700); +} + +/* Attendance Progress */ +.attendance-cell { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.progress-bar { + width: 4.5rem; + height: 0.375rem; + background: var(--gray-100); + border-radius: var(--radius-full); + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: var(--radius-full); + transition: width var(--transition-base); +} + +.progress-fill.progress-high { background: var(--success-500); } +.progress-fill.progress-medium { background: var(--warning-500); } +.progress-fill.progress-low { background: var(--danger-500); } + +.progress-text { + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-600); + min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ---------- Empty State ---------- */ +.empty-state { + text-align: center; + padding: 2.5rem 1.5rem; +} + +.empty-icon-wrapper { + width: 3.5rem; + height: 3.5rem; + background: var(--gray-100); + border: 1px solid var(--gray-200); + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1rem; +} + +.empty-icon { + width: 1.5rem; + height: 1.5rem; + color: var(--gray-400); +} + +.empty-title { + font-size: 1rem; + font-weight: 600; + color: var(--gray-900); + margin: 0 0 0.375rem; +} + +.empty-text { + color: var(--gray-500); + font-size: 0.875rem; + margin: 0 0 1.25rem; + max-width: 22rem; + margin-left: auto; + margin-right: auto; +} + +.empty-action-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + background: var(--primary-600); + border: 1px solid var(--primary-600); + border-radius: var(--radius-md); + color: #fff; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} + +.empty-action-btn:hover { + background: var(--primary-700); + border-color: var(--primary-700); +} + +/* ---------- Quick Stats Card ---------- */ +.quick-stats { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.quick-stat-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.quick-stat-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.quick-stat-icon { + width: 0.875rem; + height: 0.875rem; + color: var(--gray-400); +} + +.quick-stat-label { + font-size: 0.75rem; + color: var(--gray-500); +} + +.quick-stat-value { + font-size: 1.375rem; + font-weight: 700; + color: var(--gray-900); + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.quick-stat-divider { + height: 1px; + background: var(--gray-100); +} + +/* Mini Chart */ +.mini-chart { + padding: 1rem; + background: var(--gray-50); + border: 1px solid var(--gray-100); + border-radius: var(--radius-md); +} + +.mini-chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.875rem; +} + +.mini-chart-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-600); +} + +.mini-chart-badge { + font-size: 0.75rem; + font-weight: 600; + color: var(--success-700); + background: var(--success-50); + border: 1px solid var(--success-50); + padding: 0.125rem 0.5rem; + border-radius: var(--radius-full); +} + +.mini-chart-bars { + display: flex; + align-items: flex-end; + gap: 0.375rem; + height: 3.5rem; +} + +.chart-bar { + flex: 1; + background: var(--gray-200); + border-radius: 3px 3px 0 0; + transition: background var(--transition-fast); +} + +.chart-bar:hover { background: var(--primary-400, #60a5fa); } + +.chart-bar-active { background: var(--primary-600); } + +/* ---------- Responsive ---------- */ +@media (max-width: 767px) { + .dashboard-container { padding: 1rem; } + .welcome-section { padding: 1.25rem; } + .welcome-title { font-size: 1.375rem; } + .stat-value { font-size: 1.625rem; } + .bento-card { padding: 1rem; } + .table-wrapper { margin: 0 -1rem; padding: 0 1rem; } + .data-table { font-size: 0.8125rem; } + .data-table th, + .data-table td { padding: 0.625rem 0.5rem; } + .card-action-btn span { display: none; } /* icon-only on mobile */ +} + +/* ---------- Accessibility ---------- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +button:focus-visible, +.card-action-btn:focus-visible, +.empty-action-btn:focus-visible { + outline: 2px solid var(--primary-500); + outline-offset: 2px; +} + +@media (prefers-contrast: high) { + .stat-card, + .bento-card { border: 2px solid var(--gray-900); } +} \ No newline at end of file diff --git a/facerecognition_presensi/Website/src/pages/Login.jsx b/facerecognition_presensi/Website/src/pages/Login.jsx new file mode 100644 index 0000000..d40525c --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/Login.jsx @@ -0,0 +1,190 @@ +// src/pages/Login.jsx +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { login } from "../utils/apiUtils"; +import { setToken, setUser, setRole, setUsername } from "../utils/auth"; +import { + Eye, + EyeOff, + Mail, + Lock, + ArrowRight, + GraduationCap, +} from "lucide-react"; + +export default function LoginPage() { + const [showPassword, setShowPassword] = useState(false); + const [username, setUsernameInput] = useState(""); + const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [errorMsg, setErrorMsg] = useState(""); + const navigate = useNavigate(); + + useEffect(() => { + const savedEmail = localStorage.getItem("rememberMeEmail"); + const savedPassword = localStorage.getItem("rememberMePassword"); + const savedRememberMe = localStorage.getItem("rememberMeCheckbox") === "true"; + + if (savedEmail) setUsernameInput(savedEmail); + if (savedPassword) setPassword(savedPassword); + if (savedRememberMe) setRememberMe(true); + }, []); + + const handleLogin = async (e) => { + e.preventDefault(); + setErrorMsg(""); + + if (!username.includes("@")) { + setErrorMsg("Email harus mengandung karakter '@'"); + return; + } + + setIsLoading(true); + + try { + const data = await login(username, password); + // login returns { access_token, user: { id, name, email, role } } + setToken(data.access_token, rememberMe); + setUser(data.user, rememberMe); + setRole(data.user.role, rememberMe); + setUsername(data.user.name, rememberMe); + + if (rememberMe) { + localStorage.setItem("rememberMeEmail", username); + localStorage.setItem("rememberMePassword", password); + localStorage.setItem("rememberMeCheckbox", "true"); + } else { + localStorage.removeItem("rememberMeEmail"); + localStorage.removeItem("rememberMePassword"); + localStorage.removeItem("rememberMeCheckbox"); + } + + navigate("/dashboard"); + } catch (error) { + setErrorMsg(error.message || "Gagal masuk. Periksa kembali username dan password."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ +
+

+ E-Learn System +

+

Masuk ke akun Anda

+
+ + {/* Info Banner & Error Messages */} + {errorMsg && ( +
+

{errorMsg}

+
+ )} + + {/* Form */} +
+ {/* Username Input */} +
+ +
+
+ +
+ setUsernameInput(e.target.value)} + placeholder="Masukkan email" + className="w-full pl-11 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all bg-gray-50 focus:bg-white" + disabled={isLoading} + required + /> +
+
+ + {/* Password Input */} +
+ +
+
+ +
+ setPassword(e.target.value)} + placeholder="Masukkan password" + className="w-full pl-11 pr-12 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all bg-gray-50 focus:bg-white" + disabled={isLoading} + required + /> + +
+
+ + {/* Remember */} +
+ +
+ + {/* Login Button */} + +
+ + {/* Divider */} +
+

+ © 2025 E-Learn System. All rights reserved. +

+
+
+
+ ); +} diff --git a/facerecognition_presensi/Website/src/pages/dashboard.jsx b/facerecognition_presensi/Website/src/pages/dashboard.jsx new file mode 100644 index 0000000..7cade01 --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/dashboard.jsx @@ -0,0 +1,411 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Users, + BookOpen, + CheckCircle, + Calendar, + TrendingUp, + Clock, + Eye, + ChevronRight, + BarChart3, + Sparkles, + Search, +} from "lucide-react"; +import DashboardLayout from "../layouts/dashboardlayout"; +import { navigationItems } from "../navigation/navigation"; +import { getDashboardData } from "../utils/apiUtils"; +import { getToken, getUser, clearAuth } from "../utils/auth"; +import "./Dashboard.css"; + +const Dashboard = () => { + const [activeNav, setActiveNav] = useState("Dashboard"); + const [userInfo, setUserInfo] = useState(null); + const [dashboardData, setDashboardData] = useState(null); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("Semua"); + const navigate = useNavigate(); + + useEffect(() => { + const token = getToken(); + if (!token) { + navigate('/login'); + return; + } + + const user = getUser(); + setUserInfo(user); + loadDashboardData(); + }, [navigate]); + + const loadDashboardData = async (showLoading = true) => { + try { + if (showLoading) setLoading(true); + const data = await getDashboardData(); + setDashboardData(data); + } catch (error) { + console.error('Error loading dashboard:', error); + setDashboardData({ + stats: { + total_kelas_mk: 0, + total_mahasiswa: 0, + total_materi: 0, + presensi_hari_ini: 0, + }, + recent_presensi: [], + }); + } finally { + if (showLoading) setLoading(false); + } + }; + + const handleLogout = () => { + clearAuth(); + window.location.href = '/login'; + }; + + const formatDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString('id-ID', { + day: 'numeric', + month: 'short', + year: 'numeric' + }); + }; + + const getGreeting = () => { + const hour = new Date().getHours(); + if (hour < 12) return 'Selamat Pagi'; + if (hour < 15) return 'Selamat Siang'; + if (hour < 18) return 'Selamat Sore'; + return 'Selamat Malam'; + }; + + if (loading || !userInfo) { + return ( +
+
+
+ +
+

Memuat Dashboard...

+
+
+ ); + } + + const stats = dashboardData ? [ + { + title: "Total Kelas MK", + value: dashboardData.stats.total_kelas_mk.toString(), + icon: BookOpen, + color: "blue", + trend: null, + }, + { + title: "Total Mahasiswa", + value: dashboardData.stats.total_mahasiswa.toString(), + icon: Users, + color: "green", + trend: null, + }, + { + title: "Presensi Hari Ini", + value: dashboardData.stats.presensi_hari_ini.toString(), + icon: CheckCircle, + color: "orange", + trend: dashboardData.stats.presensi_hari_ini > 0 ? 'up' : null, + }, + { + title: "Rata-rata Kehadiran", + value: dashboardData.stats.total_mahasiswa > 0 + ? Math.round((dashboardData.stats.presensi_hari_ini / dashboardData.stats.total_mahasiswa) * 100) + '%' + : '0%', + icon: BarChart3, + color: "purple", + trend: null, + } + ] : []; + + const filteredPresensi = dashboardData?.recent_presensi.filter(presensi => { + const query = searchQuery.toLowerCase(); + const formattedDate = formatDate(presensi.tanggal).toLowerCase(); + const persentaseHadir = presensi.total_mhs > 0 + ? Math.round((presensi.hadir / presensi.total_mhs) * 100) + : 0; + + const matchSearch = ( + presensi.nama_mk.toLowerCase().includes(query) || + presensi.kelas.toLowerCase().includes(query) || + presensi.pertemuan_ke.toString().includes(query) || + formattedDate.includes(query) || + presensi.hadir.toString().includes(query) || + presensi.alfa.toString().includes(query) || + `${presensi.hadir}/${presensi.total_mhs}`.includes(query) || + `${persentaseHadir}%`.includes(query) || + persentaseHadir.toString().includes(query) + ); + + let matchStatus = true; + if (statusFilter === "Sempurna") { + matchStatus = presensi.alfa === 0; + } else if (statusFilter === "Ada Alfa") { + matchStatus = presensi.alfa > 0; + } else if (statusFilter === "< 75%") { + matchStatus = persentaseHadir < 75; + } + + return matchSearch && matchStatus; + }) || []; + + return ( + +
+ {/* Welcome Hero Section */} +
+
+
+
+ + {getGreeting()} +
+

+ {userInfo?.name || userInfo?.nama || userInfo?.username || 'User'} +

+

+ Dashboard E-Learning +

+
+
+
+ +
+
+
+
+
+
+
+
+ + {/* Stats Bento Grid */} +
+ {stats.map((stat, index) => { + const Icon = stat.icon; + return ( +
+
+
+

{stat.title}

+

{stat.value}

+ {stat.trend && ( +
+ + {stat.trend === 'up' ? 'Naik' : 'Turun'} +
+ )} +
+
+ +
+
+
+
+ ); + })} +
+ + {/* Recent Presensi Bento Grid */} +
+ {/* Recent Presensi Table - Large Card */} +
+
+
+
+ +
+
+

Presensi Terbaru

+

5 presensi terakhir yang dibuat

+
+
+
+ +
+ + setSearchQuery(e.target.value)} + /> +
+ +
+
+ + {filteredPresensi.length > 0 ? ( +
+ + + + + + + + + + + + + + {filteredPresensi.map((presensi, index) => { + const persentaseHadir = presensi.total_mhs > 0 + ? Math.round((presensi.hadir / presensi.total_mhs) * 100) + : 0; + + return ( + + + + + + + + + + ); + })} + +
Mata KuliahKelasMingguTanggalHadirAlfaKehadiran
+
{presensi.nama_mk}
+
+ {presensi.kelas} + + {presensi.pertemuan_ke} + + {formatDate(presensi.tanggal)} + + + {presensi.hadir}/{presensi.total_mhs} + + + + {presensi.alfa} + + +
+
+
= 75 ? 'progress-high' : + persentaseHadir >= 50 ? 'progress-medium' : 'progress-low' + }`} + style={{ width: `${persentaseHadir}%` }} + >
+
+ {persentaseHadir}% +
+
+
+ ) : ( +
+
+ +
+

Belum Ada Presensi

+

+ Generate presensi pertama Anda untuk mulai tracking kehadiran +

+ +
+ )} +
+ + {/* Quick Stats Card */} +
+
+
+
+ +
+
+

Statistik Cepat

+

Ringkasan mingguan

+
+
+
+ +
+
+
+ + Rata-rata Durasi +
+

15 menit

+
+ +
+ +
+
+ + Total View +
+

1,234

+
+
+ +
+
+ Tren Mingguan + +12% +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; + +export default Dashboard; diff --git a/facerecognition_presensi/Website/src/pages/detailMateri.jsx b/facerecognition_presensi/Website/src/pages/detailMateri.jsx new file mode 100644 index 0000000..34d6cc0 --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/detailMateri.jsx @@ -0,0 +1,150 @@ +// src/pages/detailMateri.jsx +import DashboardLayout from "../layouts/dashboardlayout"; +import { useState, useEffect } from "react"; +import { navigationItems } from "../navigation/navigation"; +import { BookOpen, ArrowRight, ArrowLeft, AlertCircle } from "lucide-react"; +import { useNavigate, useParams } from "react-router-dom"; +import { apiGet } from "../utils/apiUtils"; + +export default function DetailMateri() { + const [activeNav, setActiveNav] = useState("materi"); + const [kelasMKInfo, setKelasMKInfo] = useState(null); + const [materiList, setMateriList] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { id_kelas_mk } = useParams(); + const navigate = useNavigate(); + + useEffect(() => { + console.log("🔵 DetailMateri mounted, id_kelas_mk:", id_kelas_mk); + fetchKelasMKDetail(); + }, [id_kelas_mk]); + + const fetchKelasMKDetail = async () => { + try { + setLoading(true); + setError(null); + + console.log("🔵 Fetching kelas mata kuliah detail:", id_kelas_mk); + + // Fetch kelas_mata_kuliah detail to get kode_mk and id_kelas + const kelasMKData = await apiGet(`/kelas-mata-kuliah/${id_kelas_mk}`); + console.log("✅ Kelas MK data:", kelasMKData); + setKelasMKInfo(kelasMKData); + + // Fetch all materi using kode_mk + id_kelas (shared across multiple dosen) + const { kode_mk, id_kelas } = kelasMKData; + console.log("🔵 Fetching materi for kode_mk:", kode_mk, "id_kelas:", id_kelas); + const materiData = await apiGet(`/materi?kode_mk=${kode_mk}&id_kelas=${id_kelas}`); + console.log("✅ Materi data:", materiData); + + // Create weeks structure with actual materi count + const weeks = Array.from({ length: 16 }, (_, i) => { + const mingguNum = i + 1; + const materiForWeek = materiData.filter(m => m.minggu === mingguNum); + return { + minggu: mingguNum, + judul: `Minggu ${mingguNum}`, + materi_count: materiForWeek.length + }; + }); + setMateriList(weeks); + + } catch (error) { + console.error("❌ Error fetching kelas mata kuliah:", error); + setError(error.message || "Failed to load data"); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( + +
+
+

Loading mata kuliah...

+
+
+ ); + } + + if (error) { + return ( + +
+ +
+

Error Loading Data

+

{error}

+
+ + +
+
+
+
+ ); + } + + return ( + +
+
+
+

+ Materi Perkuliahan +

+ {kelasMKInfo && ( +
+

+ {kelasMKInfo.nama_mk} ({kelasMKInfo.kode_mk}) +

+

+ {kelasMKInfo.nama_kelas} • {kelasMKInfo.prodi} • {kelasMKInfo.tahun_ajaran} • Semester {kelasMKInfo.semester_aktif} +

+
+ )} +
+ +
+ + {/* Minggu List */} +
+ {materiList.map((week) => ( +
navigate(`/materi/${id_kelas_mk}/minggu/${week.minggu}`)} + className="border border-gray-200 rounded-xl p-6 bg-white hover:shadow-lg transition cursor-pointer hover:border-blue-400 group" + > +
+

+ Minggu {week.minggu} +

+ +
+

+ {week.materi_count} materi tersedia +

+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/facerecognition_presensi/Website/src/pages/detailPresensi.jsx b/facerecognition_presensi/Website/src/pages/detailPresensi.jsx new file mode 100644 index 0000000..a0dfb00 --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/detailPresensi.jsx @@ -0,0 +1,497 @@ +import Swal from "sweetalert2"; +import DashboardLayout from "../layouts/dashboardlayout"; +import { useState, useEffect } from "react"; +import { navigationItems } from "../navigation/navigation"; +import { useParams, useNavigate } from "react-router-dom"; +import { + ArrowLeft, + CheckCircle, + XCircle, + AlertTriangle, + Clock, + Users, + BookOpen, + Calendar, + Download, + Edit2, + Save, + X, + UserCheck, + AlertCircle, +} from "lucide-react"; +import { API_BASE_URL } from "../utils/apiUtils"; +import { getToken, getUser } from "../utils/auth"; + +// Mapping status backend → tampilan frontend +const STATUS_MAP = { + belum: { label: "Belum Presensi", color: "bg-yellow-100 text-yellow-800 border-yellow-300", icon: Clock }, + hadir: { label: "Hadir", color: "bg-green-100 text-green-800 border-green-300", icon: CheckCircle }, + terlambat: { label: "Terlambat", color: "bg-orange-100 text-orange-800 border-orange-300", icon: AlertTriangle }, + izin: { label: "Izin", color: "bg-blue-100 text-blue-800 border-blue-300", icon: AlertCircle }, + sakit: { label: "Sakit", color: "bg-purple-100 text-purple-800 border-purple-300", icon: AlertCircle }, + alpha: { label: "Alpha", color: "bg-red-100 text-red-800 border-red-300", icon: XCircle }, +}; + +// Opsi pilihan status untuk form edit +const STATUS_OPTIONS = [ + { value: "belum", label: "Belum Presensi" }, + { value: "hadir", label: "Hadir" }, + { value: "terlambat", label: "Terlambat" }, + { value: "izin", label: "Izin" }, + { value: "sakit", label: "Sakit" }, + { value: "alpha", label: "Alpha" }, +]; + +export default function DetailPresensi() { + const [activeNav, setActiveNav] = useState("presensi"); + const { id_kelas_mk } = useParams(); // ini adalah id_pertemuan + const navigate = useNavigate(); + + const [daftarAbsen, setDaftarAbsen] = useState([]); + const [pertemuanInfo, setPertemuanInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [notification, setNotification] = useState({ show: false, type: "", message: "" }); + + // State edit modal + const [showEditModal, setShowEditModal] = useState(false); + const [editingPresensi, setEditingPresensi] = useState(null); + const [editStatus, setEditStatus] = useState("belum"); + const [saving, setSaving] = useState(false); + + const currentUser = getUser() || {}; + const isAdminOrDosen = currentUser.role === "admin" || currentUser.role === "dosen"; + + useEffect(() => { + loadData(); + const intervalId = setInterval(() => { + loadData(false); + }, 5000); + return () => clearInterval(intervalId); + }, [id_kelas_mk]); + + const showNotification = (type, message) => { + Swal.fire({ + icon: type === 'error' ? 'error' : (type === 'success' ? 'success' : 'info'), + title: type === 'error' ? 'Oops...' : (type === 'success' ? 'Berhasil' : 'Info'), + text: message, + timer: 3000, + showConfirmButton: false + }); + }; + + const loadData = async (showLoading = true) => { + try { + if (showLoading) setLoading(true); + const token = getToken(); + const headers = { Authorization: `Bearer ${token}` }; + + // Ambil data presensi (rekap sudah include nim + nama_mahasiswa) + const rekapRes = await fetch( + `${API_BASE_URL}/presensi/rekap/pertemuan/${id_kelas_mk}`, + { headers } + ); + + if (!rekapRes.ok) { + showNotification("error", "Data presensi tidak ditemukan"); + setTimeout(() => navigate("/presensi"), 1500); + return; + } + + const rekapData = await rekapRes.json(); + setDaftarAbsen(rekapData || []); + + // Ambil info pertemuan untuk header + const pertemuanRes = await fetch( + `${API_BASE_URL}/pertemuan/${id_kelas_mk}`, + { headers } + ); + if (pertemuanRes.ok) { + const p = await pertemuanRes.json(); + setPertemuanInfo(p); + } + } catch (error) { + console.error("Error loading detail presensi:", error); + showNotification("error", "Gagal memuat data presensi"); + } finally { + setLoading(false); + } + }; + + // Buka modal edit + const handleEdit = (item) => { + setEditingPresensi(item); + setEditStatus(item.status_presensi || "belum"); + setShowEditModal(true); + }; + + // Simpan perubahan status + const handleSave = async () => { + if (!editingPresensi) return; + setSaving(true); + try { + const token = getToken(); + const res = await fetch(`${API_BASE_URL}/presensi/${editingPresensi.id_presensi}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ status_presensi: editStatus }), + }); + + if (res.ok) { + showNotification("success", "✓ Status presensi berhasil diperbarui!"); + setShowEditModal(false); + setEditingPresensi(null); + // Update data lokal tanpa reload penuh + setDaftarAbsen((prev) => + prev.map((item) => + item.id_presensi === editingPresensi.id_presensi + ? { ...item, status_presensi: editStatus } + : item + ) + ); + } else { + const err = await res.json().catch(() => ({})); + showNotification("error", `❌ Gagal update: ${err.detail || "Unknown error"}`); + } + } catch (err) { + console.error("Error saving status:", err); + showNotification("error", "❌ Terjadi kesalahan saat menyimpan"); + } finally { + setSaving(false); + } + }; + + const handleExport = () => { + let csv = `DAFTAR PRESENSI MAHASISWA\n`; + csv += `Pertemuan ke-,${pertemuanInfo?.pertemuan_ke || id_kelas_mk}\n`; + csv += `Tanggal,${pertemuanInfo?.tanggal || "-"}\n\n`; + csv += `No,NIM,Nama Mahasiswa,Status Presensi,Waktu Presensi\n`; + daftarAbsen.forEach((item, i) => { + const waktu = item.waktu_presensi + ? new Date(item.waktu_presensi).toLocaleTimeString("id-ID") + : "-"; + csv += `${i + 1},${item.nim},"${item.nama_mahasiswa}",${STATUS_MAP[item.status_presensi]?.label || item.status_presensi},${waktu}\n`; + }); + const BOM = "\uFEFF"; + const blob = new Blob([BOM + csv], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `Presensi_Pertemuan${pertemuanInfo?.pertemuan_ke || id_kelas_mk}_${pertemuanInfo?.tanggal || ""}.csv`; + link.click(); + }; + + const getStatusBadge = (statusKey) => { + const s = STATUS_MAP[statusKey] || STATUS_MAP["belum"]; + const Icon = s.icon; + return ( + + + {s.label} + + ); + }; + + // Statistik + const total = daftarAbsen.length; + const countHadir = daftarAbsen.filter((m) => m.status_presensi === "hadir").length; + const countTerlambat = daftarAbsen.filter((m) => m.status_presensi === "terlambat").length; + const countIzin = daftarAbsen.filter((m) => m.status_presensi === "izin").length; + const countSakit = daftarAbsen.filter((m) => m.status_presensi === "sakit").length; + const countAlpha = daftarAbsen.filter((m) => m.status_presensi === "alpha").length; + const countBelum = daftarAbsen.filter((m) => m.status_presensi === "belum").length; + const persentase = total > 0 ? (((countHadir + countTerlambat) / total) * 100).toFixed(1) : 0; + + if (loading) { + return ( + +
+
+
+

Memuat data presensi...

+
+
+
+ ); + } + + return ( + + {/* Notification */} + {notification.show && ( +
+
+ {notification.type === "success" ? "✓" : "✕"} {notification.message} +
+
+ )} + +
+ + {/* Header */} +
+ + +
+
+

+ Detail Presensi Mahasiswa +

+ {pertemuanInfo && ( +

+ + Pertemuan ke-{pertemuanInfo.pertemuan_ke}  ·  + {new Date(pertemuanInfo.tanggal + "T00:00:00").toLocaleDateString("id-ID", { + weekday: "long", day: "numeric", month: "long", year: "numeric", + })} +

+ )} +
+ +
+
+ + {/* Statistik */} +
+ {[ + { label: "Total", value: total, bg: "bg-white border-gray-200", text: "text-gray-900" }, + { label: "Hadir", value: countHadir, bg: "bg-green-50 border-green-200", text: "text-green-700" }, + { label: "Terlambat", value: countTerlambat, bg: "bg-orange-50 border-orange-200", text: "text-orange-700" }, + { label: "Izin", value: countIzin, bg: "bg-blue-50 border-blue-200", text: "text-blue-700" }, + { label: "Sakit", value: countSakit, bg: "bg-purple-50 border-purple-200", text: "text-purple-700" }, + { label: "Alpha", value: countAlpha, bg: "bg-red-50 border-red-200", text: "text-red-700" }, + ].map((stat) => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+ + {/* Info belum presensi & persentase */} +
+
+ + Belum Presensi: {countBelum} +
+
+ + Kehadiran: {persentase}% +
+
+ + {/* Tabel */} +
+
+ + + + + + + + + {isAdminOrDosen && ( + + )} + + + + {daftarAbsen.length === 0 ? ( + + + + ) : ( + daftarAbsen.map((item, index) => ( + + + + + + + {isAdminOrDosen && ( + + )} + + )) + )} + +
NoNIMNama MahasiswaStatusWaktu PresensiAksi
+ + Belum ada data mahasiswa untuk pertemuan ini +
{index + 1}{item.nim}{item.nama_mahasiswa}{getStatusBadge(item.status_presensi)} + {item.waktu_presensi ? ( + + + {new Date(item.waktu_presensi).toLocaleTimeString("id-ID")} + + ) : ( + — + )} + + +
+
+
+ + {/* Progress Bar */} + {total > 0 && ( +
+

Progress Kehadiran

+
+ {countHadir > 0 && ( +
+ )} + {countTerlambat > 0 && ( +
+ )} + {countIzin > 0 && ( +
+ )} + {countSakit > 0 && ( +
+ )} + {countAlpha > 0 && ( +
+ )} + {countBelum > 0 && ( +
+ )} +
+
+ {[ + { label: "Hadir", count: countHadir, color: "bg-green-500" }, + { label: "Terlambat", count: countTerlambat, color: "bg-orange-400" }, + { label: "Izin", count: countIzin, color: "bg-blue-400" }, + { label: "Sakit", count: countSakit, color: "bg-purple-400" }, + { label: "Alpha", count: countAlpha, color: "bg-red-500" }, + { label: "Belum Presensi", count: countBelum, color: "bg-yellow-300" }, + ].map((item) => ( +
+
+ {item.label} ({item.count}) +
+ ))} +
+
+ )} + +
+ + {/* ── Modal Edit Status ── */} + {showEditModal && editingPresensi && ( +
{ if (e.target === e.currentTarget) setShowEditModal(false); }} + > +
+ {/* Header modal */} +
+

+ + Edit Status Presensi +

+ +
+ + {/* Info mahasiswa */} +
+
+ NIM + {editingPresensi.nim} +
+
+ Nama + {editingPresensi.nama_mahasiswa} +
+
+ Status Saat Ini + {getStatusBadge(editingPresensi.status_presensi)} +
+
+ + {/* Pilih status baru */} +
+ +
+ {STATUS_OPTIONS.map((opt) => { + const s = STATUS_MAP[opt.value]; + const Icon = s.icon; + const isSelected = editStatus === opt.value; + return ( + + ); + })} +
+
+ + {/* Action buttons */} +
+ + +
+
+
+ )} + + + + ); +} diff --git a/facerecognition_presensi/Website/src/pages/detailPresensiAbsen.jsx b/facerecognition_presensi/Website/src/pages/detailPresensiAbsen.jsx new file mode 100644 index 0000000..77925f5 --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/detailPresensiAbsen.jsx @@ -0,0 +1,644 @@ +import Swal from "sweetalert2"; +import DashboardLayout from "../layouts/dashboardlayout"; +import { useState, useEffect } from "react"; +import { navigationItems } from "../navigation/navigation"; +import { ArrowLeft, UserCheck, Users, BookOpen, Calendar, Download, CheckCircle, XCircle, Clock, AlertCircle, Edit2, Save, X, Archive } from "lucide-react"; +import { useNavigate, useParams } from "react-router-dom"; +import { getToken, getUser } from "../utils/auth"; +import { API_BASE_URL } from "../utils/apiUtils"; + +export default function DetailPresensiAbsen() { + const [activeNav, setActiveNav] = useState("presensi"); + const navigate = useNavigate(); + const { id_kelas_mk, tanggal, pertemuan_ke } = useParams(); // Get params from URL + + const [daftarAbsen, setDaftarAbsen] = useState([]); + const [loading, setLoading] = useState(true); + const [notification, setNotification] = useState({ show: false, type: '', message: '' }); + const [detailPresensi, setDetailPresensi] = useState({ + kelas: "", + matkul: "", + kode_mk: "", + minggu: 0, + tanggal: "", + waktu_mulai: "", + waktu_selesai: "", + status_pertemuan: "belum", // default status + is_aktif: true + }); + + // State untuk edit status + const [showEditModal, setShowEditModal] = useState(false); + const [editingPresensi, setEditingPresensi] = useState(null); + const [editForm, setEditForm] = useState({ + status: "" + }); + + // Get current user role + const currentUser = getUser() || {}; + const isAdminOrSuperAdmin = currentUser.role === 'admin' || currentUser.role === 'dosen'; + + // Load data dari backend + useEffect(() => { + loadDetailPresensi(); + const intervalId = setInterval(() => { + loadDetailPresensi(false); + }, 5000); + return () => clearInterval(intervalId); + }, [id_kelas_mk, tanggal, pertemuan_ke]); + + const showNotification = (type, message) => { + Swal.fire({ + icon: type === 'error' ? 'error' : (type === 'success' ? 'success' : 'info'), + title: type === 'error' ? 'Oops...' : (type === 'success' ? 'Berhasil' : 'Info'), + text: message, + timer: 3000, + showConfirmButton: false + }); + }; + + const loadDetailPresensi = async (showLoading = true) => { + try { + if (showLoading) setLoading(true); + const token = getToken(); + + // Fetch pertemuan info untuk mendapatkan status + const pertemuanRes = await fetch(`${API_BASE_URL}/pertemuan/${id_kelas_mk}`, { + headers: { Authorization: `Bearer ${token}` } + }); + + const response = await fetch(`${API_BASE_URL}/presensi/?id_pertemuan=${id_kelas_mk}`, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (showLoading) setLoading(true);\n if (response.ok) { + const data = await response.json(); + + if (data.length > 0) { + // Set data absen mahasiswa + setDaftarAbsen(data); + + // Load info kelas mata kuliah + loadInfoKelasMK(id_kelas_mk); + + // Ambil waktu_mulai dan waktu_selesai dari data presensi pertama + // (semua mahasiswa di sesi yang sama memiliki waktu yang sama) + const firstRecord = data[0]; + + // Tentukan apakah presensi aktif atau tidak berdasarkan status_pertemuan + let statusPertemuan = "belum"; + let isAktif = true; + + if (pertemuanRes.ok) { + const pertemuanData = await pertemuanRes.json(); + statusPertemuan = pertemuanData.status_pertemuan || "belum"; + // Tidak aktif jika status adalah selesai atau dibatalkan + isAktif = !["selesai", "dibatalkan"].includes(statusPertemuan); + } + + setDetailPresensi(prev => ({ + ...prev, + minggu: parseInt(pertemuan_ke), + tanggal: tanggal, + waktu_mulai: firstRecord.waktu_mulai || "00:00", + waktu_selesai: firstRecord.waktu_selesai || "00:00", + status_pertemuan: statusPertemuan, + is_aktif: isAktif + })); + } + } else { + if (showLoading) showNotification('error', "Data presensi tidak ditemukan"); + if (showLoading) setTimeout(() => navigate("/presensi"), 1500); + } + } catch (error) { + console.error("Error loading detail presensi:", error); + if (showLoading) showNotification('error', "Gagal memuat data presensi"); + } finally { + if (showLoading) setLoading(false); + } + }; + + const loadInfoKelasMK = async (id_kelas_mk) => { + try { + const token2 = getToken(); + const response = await fetch(`${API_BASE_URL}/kelas-mata-kuliah/${id_kelas_mk}`, { + headers: { Authorization: `Bearer ${token2}` } + }); + if (response.ok) { + const data = await response.json(); + setDetailPresensi(prev => ({ + ...prev, + kelas: data.nama_kelas, + matkul: data.nama_mk, + kode_mk: data.kode_mk + })); + } + } catch (error) { + console.error("Error loading kelas mata kuliah:", error); + } + }; + + const handleKembali = () => { + navigate("/presensi"); + }; + + const handleEditStatus = (mahasiswa) => { + setEditingPresensi(mahasiswa); + setEditForm({ + status: mahasiswa.status + }); + setShowEditModal(true); + }; + + const handleSaveEditStatus = async () => { + try { + const token = getToken(); + if (!token) { + showNotification('error', 'Token otentikasi tidak ditemukan. Silakan login ulang.'); + return; + } + const response = await fetch(`${API_BASE_URL}/presensi/${editingPresensi.id_presensi}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + status_presensi: editForm.status + }) + }); + + if (response.ok) { + showNotification('success', '✓ Status presensi berhasil diupdate!'); + setShowEditModal(false); + setEditingPresensi(null); + setEditForm({ status: "" }); + + // Reload data + loadDetailPresensi(); + } else { + const errorData = await response.json(); + showNotification('error', `❌ Gagal update status: ${errorData.detail || 'Unknown error'}`); + } + } catch (error) { + console.error('Error updating presensi status:', error); + showNotification('error', '❌ Terjadi kesalahan saat mengupdate status'); + } + }; + + const handleCancelEdit = () => { + setShowEditModal(false); + setEditingPresensi(null); + setEditForm({ status: "" }); + }; + + const handleExport = () => { + // Export data ke CSV + const csvContent = generateCSV(); + downloadCSV(csvContent, `Presensi_${detailPresensi.kelas}_${detailPresensi.matkul}_Minggu${detailPresensi.minggu}_${detailPresensi.tanggal}.csv`); + }; + + const generateCSV = () => { + // Header info + let csv = `DAFTAR PRESENSI MAHASISWA\n`; + csv += `Kelas,${detailPresensi.kelas}\n`; + csv += `Mata Kuliah,${detailPresensi.matkul} (${detailPresensi.kode_mk})\n`; + csv += `Minggu,${detailPresensi.minggu}\n`; + csv += `Tanggal,${new Date(detailPresensi.tanggal).toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}\n`; + csv += `Waktu,${detailPresensi.waktu_mulai || '00:00'} - ${detailPresensi.waktu_selesai || '00:00'}\n`; + csv += `\n`; + + // Statistik + csv += `Total Mahasiswa,${totalMahasiswa}\n`; + csv += `Hadir,${hadir}\n`; + csv += `Alpa,${alpa}\n`; + csv += `Persentase Kehadiran,${persentaseKehadiran}%\n`; + csv += `\n`; + + // Header tabel + csv += `No,NIM,Nama Mahasiswa,Status,Waktu Absen\n`; + + // Data mahasiswa + daftarAbsen.forEach((mahasiswa, index) => { + const waktuAbsen = mahasiswa.waktu_input + ? new Date(mahasiswa.waktu_input).toLocaleTimeString('id-ID') + : '-'; + csv += `${index + 1},${mahasiswa.nim},"${mahasiswa.nama_mahasiswa}",${mahasiswa.status},${waktuAbsen}\n`; + }); + + return csv; + }; + + const downloadCSV = (content, filename) => { + // Add BOM for Excel to recognize UTF-8 + const BOM = '\uFEFF'; + const blob = new Blob([BOM + content], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', filename); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const getStatusBadge = (status) => { + const badges = { + "Hadir": { + color: "bg-green-100 text-green-800 border-green-300", + icon: CheckCircle, + text: "Hadir" + }, + "Belum Absen": { + color: "bg-yellow-100 text-yellow-800 border-yellow-300", + icon: Clock, + text: "Belum Absen" + }, + "Alfa": { + color: "bg-red-100 text-red-800 border-red-300", + icon: XCircle, + text: "Alfa" + } + }; + const badge = badges[status] || badges["Belum Absen"]; + const Icon = badge.icon; + return ( + + + {badge.text} + + ); + }; + + // Hitung statistik + const totalMahasiswa = daftarAbsen.length; + const hadir = daftarAbsen.filter(m => m.status === 'Hadir').length; + const belumAbsen = daftarAbsen.filter(m => m.status === 'Belum Absen').length; + const alpa = daftarAbsen.filter(m => m.status === 'Alfa').length; + const persentaseKehadiran = totalMahasiswa > 0 ? ((hadir / totalMahasiswa) * 100).toFixed(1) : 0; + + if (loading) { + return ( + +
+
+
+

Memuat data...

+
+
+
+ ); + } + + return ( + + {/* Notification */} + {notification.show && ( +
+
+ {notification.type === 'success' ? '✓' : '✕'} {notification.message} +
+
+ )} + +
+ + {/* Header dengan Tombol Kembali */} +
+ + +
+
+
+ {!detailPresensi.is_aktif && ( + + Tidak Aktif + + )} +
+

+ Detail Presensi Mahasiswa +

+

+ {detailPresensi.is_aktif + ? "Daftar kehadiran mahasiswa untuk minggu ini" + : "Riwayat presensi - tidak dapat lagi melakukan presensi via mobile" + } +

+
+ + +
+
+ + {/* Info Presensi */} +
+
+

Informasi Presensi

+ {!detailPresensi.is_aktif && ( + + Tidak Aktif + + )} +
+
+
+
+ +
+
+

Kelas

+

{detailPresensi.kelas}

+
+
+ +
+
+ +
+
+

Mata Kuliah

+

{detailPresensi.matkul}

+

{detailPresensi.kode_mk}

+
+
+ +
+
+ +
+
+

Minggu & Tanggal

+

Minggu {detailPresensi.minggu}

+

+ {new Date(detailPresensi.tanggal).toLocaleDateString('id-ID', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + })} +

+
+
+
+ +
+
+ +

+ Waktu Presensi: {detailPresensi.waktu_mulai || "00:00"} - {detailPresensi.waktu_selesai || "00:00"} +

+ + {detailPresensi.is_aktif ? '● Aktif' : '○ Tidak Aktif'} + +
+
+
+ + {/* Statistik Ringkasan */} +
+
+

{totalMahasiswa}

+

Total

+
+
+

{hadir}

+

Hadir

+
+
+

{belumAbsen}

+

Belum Absen

+
+
+

{alpa}

+

Alpa

+
+
+

{persentaseKehadiran}%

+

Kehadiran

+
+
+ + {/* Tabel Daftar Absen */} +
+
+ + + + + + + + + + {isAdminOrSuperAdmin && ( + + )} + + + + {daftarAbsen.map((mahasiswa, index) => ( + + + + + + + + {isAdminOrSuperAdmin && ( + + )} + + ))} + +
NoNIMNama MahasiswaStatusWaktu AbsenVerifikasi FotoAksi
+ {index + 1} + + {mahasiswa.nim} + + {mahasiswa.nama_mahasiswa} + + {getStatusBadge(mahasiswa.status)} + + {mahasiswa.waktu_input ? ( + + + {new Date(mahasiswa.waktu_input).toLocaleTimeString('id-ID')} + + ) : ( + - + )} + + {mahasiswa.status === 'Hadir' ? ( + + ✓ Terverifikasi + + ) : ( + + ✗ Tidak ada + + )} + + +
+
+
+ + {/* Progress Bar Kehadiran */} +
+

Progress Kehadiran

+
+
+
+ {hadir > 0 && `${hadir}`} +
+
+ {belumAbsen > 0 && `${belumAbsen}`} +
+
+ {alpa > 0 && `${alpa}`} +
+
+
+
+
+
+ Hadir ({hadir}) +
+
+
+ Belum Absen ({belumAbsen}) +
+
+
+ Alpa ({alpa}) +
+
+
+ +
+ + {/* Edit Status Modal */} + {showEditModal && editingPresensi && ( +
+
+
+

+ + Edit Status Presensi +

+ +
+ + {/* Info Mahasiswa */} +
+
+
+ NIM: + {editingPresensi.nim} +
+
+ Nama: + {editingPresensi.nama_mahasiswa} +
+
+ Status Saat Ini: + {getStatusBadge(editingPresensi.status)} +
+
+
+ + {/* Form Edit */} +
+ + +
+ + {/* Action Buttons */} +
+ + +
+
+
+ )} + + {/* Add CSS Animations if not already present */} + +
+ ); +} diff --git a/facerecognition_presensi/Website/src/pages/detailProfilDosen.jsx b/facerecognition_presensi/Website/src/pages/detailProfilDosen.jsx new file mode 100644 index 0000000..2c97b3c --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/detailProfilDosen.jsx @@ -0,0 +1,353 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import DashboardLayout from "../layouts/dashboardlayout"; +import { navigationItems } from "../navigation/navigation"; +import { + User, Mail, Phone, MapPin, Calendar, Award, + ArrowLeft, BookOpen, BarChart3, Users +} from "lucide-react"; +import axios from "axios"; +import { getToken, getUser } from "../utils/auth"; +import { getDosenById, getKelasMKByDosen, API_BASE_URL } from "../utils/apiUtils"; + +export default function DetailProfilDosen() { + const { id_dosen } = useParams(); + const navigate = useNavigate(); + const [activeNav, setActiveNav] = useState("kelola-dosen"); + const [activeTab, setActiveTab] = useState("profil"); + const [dosen, setDosen] = useState(null); + const [assignments, setAssignments] = useState([]); + const [loading, setLoading] = useState(true); + + + + useEffect(() => { + fetchDosenDetail(); + fetchAssignments(); + }, [id_dosen]); + + const fetchDosenDetail = async () => { + try { + setLoading(true); + const data = await getDosenById(id_dosen); + setDosen(data); + } catch (error) { + console.error("Error fetching dosen:", error); + } finally { + setLoading(false); + } + }; + + const fetchAssignments = async () => { + try { + const data = await getKelasMKByDosen(id_dosen); + setAssignments(data || []); + } catch (error) { + console.error("Error fetching assignments:", error); + setAssignments([]); + } + }; + + const formatDate = (dateString) => { + if (!dateString) return '-'; + const date = new Date(dateString); + return date.toLocaleDateString('id-ID', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); + }; + + if (loading && !dosen) { + return ( + +
+
+

Loading...

+
+
+ ); + } + + if (!dosen) { + return ( + +
+

Data dosen tidak ditemukan

+ +
+
+ ); + } + + return ( + +
+ {/* Back Button */} + + + {/* Header Card */} +
+
+
+ +
+
+

{dosen.nama_dosen}

+

NIP: {dosen.nip}

+
+ + {dosen.jenis_kelamin === 'L' ? 'Laki-laki' : 'Perempuan'} + + + {assignments.length} Assignment + +
+
+
+
+ + {/* Tabs */} +
+
+
+ + + +
+
+ + {/* Tab Content */} +
+ {activeTab === "profil" && ( +
+

Informasi Pribadi

+ +
+ {/* NIP */} +
+
+ +
+
+

NIP

+

{dosen.nip}

+
+
+ + {/* Email */} +
+
+ +
+
+

Email

+

{dosen.email_dosen || dosen.email || '-'}

+
+
+ + {/* No HP */} +
+
+ +
+
+

No HP

+

{dosen.no_hp || '-'}

+
+
+ + {/* Tempat, Tanggal Lahir */} +
+
+ +
+
+

Tempat, Tanggal Lahir

+

+ {dosen.tempat_lahir || '-'}, {formatDate(dosen.tanggal_lahir)} +

+
+
+ + {/* Jenis Kelamin */} +
+
+ +
+
+

Jenis Kelamin

+

+ {dosen.jenis_kelamin === 'L' ? 'Laki-laki' : dosen.jenis_kelamin === 'P' ? 'Perempuan' : '-'} +

+
+
+ + {/* Agama */} +
+
+ +
+
+

Agama

+

{dosen.agama || '-'}

+
+
+ + {/* Alamat */} +
+
+ +
+
+

Alamat

+

{dosen.alamat || '-'}

+
+
+
+
+ )} + + {activeTab === "assignment" && ( +
+

Daftar Assignment

+ + {assignments.length === 0 ? ( +
+ +

Belum ada assignment

+
+ ) : ( +
+ {assignments.map((assignment) => ( +
+
+
+

+ {assignment.nama_mk} +

+

+ Kode MK: {assignment.kode_mk} +

+
+ + {assignment.nama_kelas} + + + {assignment.prodi} + + + {assignment.tahun_ajaran} + + + Semester {assignment.semester_aktif} + +
+
+ + {assignment.status} + +
+
+ ))} +
+ )} +
+ )} + + {activeTab === "statistik" && ( +
+

Statistik

+ +
+
+
+

Total Mata Kuliah

+ +
+

{assignments.length}

+
+ +
+
+

Assignment Aktif

+ +
+

+ {assignments.filter(a => a.status === 'Aktif').length} +

+
+ +
+
+

Total Kelas

+ +
+

+ {new Set(assignments.map(a => a.id_kelas)).size} +

+
+
+ +
+

Distribusi Mata Kuliah

+
+ {assignments.map((assignment) => ( +
+ {assignment.nama_mk} + + {assignment.nama_kelas} + +
+ ))} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/facerecognition_presensi/Website/src/pages/detailProfilDosen_local.jsx b/facerecognition_presensi/Website/src/pages/detailProfilDosen_local.jsx new file mode 100644 index 0000000..42fca16 --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/detailProfilDosen_local.jsx @@ -0,0 +1,361 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import DashboardLayout from "../layouts/dashboardlayout"; +import { navigationItems } from "../navigation/navigation"; +import { + User, Mail, Phone, MapPin, Calendar, Award, + ArrowLeft, BookOpen, BarChart3, Users +} from "lucide-react"; +import axios from "axios"; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; + +export default function DetailProfilDosen() { + const { id_dosen } = useParams(); + const navigate = useNavigate(); + const [activeNav, setActiveNav] = useState("kelola-dosen"); + const [activeTab, setActiveTab] = useState("profil"); + const [dosen, setDosen] = useState(null); + const [assignments, setAssignments] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchDosenDetail(); + fetchAssignments(); + }, [id_dosen]); + + const fetchDosenDetail = async () => { + try { + setLoading(true); + const token = localStorage.getItem("token"); + const response = await axios.get(`${API_BASE_URL}/dosen/${id_dosen}`, { + headers: { Authorization: `Bearer ${token}` } + }); + setDosen(response.data); + } catch (error) { + console.error("Error fetching dosen:", error); + } finally { + setLoading(false); + } + }; + + const fetchAssignments = async () => { + try { + const token = localStorage.getItem("token"); + const response = await axios.get(`${API_BASE_URL}/kelas-mata-kuliah/dosen/${id_dosen}`, { + headers: { Authorization: `Bearer ${token}` } + }); + setAssignments(response.data); + } catch (error) { + console.error("Error fetching assignments:", error); + setAssignments([]); + } + }; + + const formatDate = (dateString) => { + if (!dateString) return '-'; + const date = new Date(dateString); + return date.toLocaleDateString('id-ID', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); + }; + + if (loading) { + return ( + +
+
+

Loading...

+
+
+ ); + } + + if (!dosen) { + return ( + +
+

Data dosen tidak ditemukan

+ +
+
+ ); + } + + return ( + +
+ {/* Back Button */} + + + {/* Header Card */} +
+
+
+ +
+
+

{dosen.nama_dosen}

+

NIP: {dosen.nip}

+
+ + {dosen.jenis_kelamin === 'L' ? 'Laki-laki' : 'Perempuan'} + + + {assignments.length} Assignment + +
+
+
+
+ + {/* Tabs */} +
+
+
+ + + +
+
+ + {/* Tab Content */} +
+ {activeTab === "profil" && ( +
+

Informasi Pribadi

+ +
+ {/* NIP */} +
+
+ +
+
+

NIP

+

{dosen.nip}

+
+
+ + {/* Email */} +
+
+ +
+
+

Email

+

{dosen.email_dosen || dosen.email || '-'}

+
+
+ + {/* No HP */} +
+
+ +
+
+

No HP

+

{dosen.no_hp || '-'}

+
+
+ + {/* Tempat, Tanggal Lahir */} +
+
+ +
+
+

Tempat, Tanggal Lahir

+

+ {dosen.tempat_lahir || '-'}, {formatDate(dosen.tanggal_lahir)} +

+
+
+ + {/* Jenis Kelamin */} +
+
+ +
+
+

Jenis Kelamin

+

+ {dosen.jenis_kelamin === 'L' ? 'Laki-laki' : dosen.jenis_kelamin === 'P' ? 'Perempuan' : '-'} +

+
+
+ + {/* Agama */} +
+
+ +
+
+

Agama

+

{dosen.agama || '-'}

+
+
+ + {/* Alamat */} +
+
+ +
+
+

Alamat

+

{dosen.alamat || '-'}

+
+
+
+
+ )} + + {activeTab === "assignment" && ( +
+

Daftar Assignment

+ + {assignments.length === 0 ? ( +
+ +

Belum ada assignment

+
+ ) : ( +
+ {assignments.map((assignment) => ( +
+
+
+

+ {assignment.nama_mk} +

+

+ Kode MK: {assignment.kode_mk} +

+
+ + {assignment.nama_kelas} + + + {assignment.prodi} + + + {assignment.tahun_ajaran} + + + Semester {assignment.semester_aktif} + +
+
+ + {assignment.status} + +
+
+ ))} +
+ )} +
+ )} + + {activeTab === "statistik" && ( +
+

Statistik

+ +
+
+
+

Total Mata Kuliah

+ +
+

{assignments.length}

+
+ +
+
+

Assignment Aktif

+ +
+

+ {assignments.filter(a => a.status === 'Aktif').length} +

+
+ +
+
+

Total Kelas

+ +
+

+ {new Set(assignments.map(a => a.id_kelas)).size} +

+
+
+ +
+

Distribusi Mata Kuliah

+
+ {assignments.map((assignment) => ( +
+ {assignment.nama_mk} + + {assignment.nama_kelas} + +
+ ))} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/facerecognition_presensi/Website/src/pages/home.jsx b/facerecognition_presensi/Website/src/pages/home.jsx new file mode 100644 index 0000000..37c4e51 --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/home.jsx @@ -0,0 +1,9 @@ +export default function Home() { + return ( +
+

+ Welcome to E-Learning 🚀 +

+
+ ) +} diff --git a/facerecognition_presensi/Website/src/pages/informasi.jsx b/facerecognition_presensi/Website/src/pages/informasi.jsx new file mode 100644 index 0000000..6f3b15c --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/informasi.jsx @@ -0,0 +1,352 @@ +import Swal from "sweetalert2"; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Edit, Trash2, AlertCircle } from 'lucide-react'; +import { + getInformasiListAdmin, + deleteInformasi +} from '../utils/apiUtils'; +import { getUser } from '../utils/auth'; +import { navigationItems } from '../navigation/navigation'; +import DashboardLayout from '../layouts/dashboardlayout'; + +const Informasi = () => { + const navigate = useNavigate(); + const [activeNav, setActiveNav] = useState('informasi'); + const [informasiList, setInformasiList] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notification, setNotification] = useState({ show: false, type: '', message: '' }); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + // Pagination + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [perPage, setPerPage] = useState(10); + + // Get current user and check role + const currentUser = getUser() || {}; + const isSuperAdmin = currentUser.role === 'admin' || currentUser.role === 'dosen'; + + useEffect(() => { + fetchInformasi(); + }, [currentPage, perPage]); + + const showNotification = (type, message) => { + Swal.fire({ + icon: type === 'error' ? 'error' : (type === 'success' ? 'success' : 'info'), + title: type === 'error' ? 'Oops...' : (type === 'success' ? 'Berhasil' : 'Info'), + text: message, + timer: 3000, + showConfirmButton: false + }); + }; + + const fetchInformasi = async () => { + try { + setLoading(true); + setError(null); + + const params = { + page: currentPage, + per_page: perPage, + }; + + const data = await getInformasiListAdmin(params); + + setInformasiList(data.items || []); + setTotalPages(data.total_pages || 1); + } catch (err) { + console.error('Error fetching informasi:', err); + setError(err.message || 'Gagal memuat data informasi'); + } finally { + setLoading(false); + } + }; + + const handleDeleteClick = (id, judul) => { + setDeleteTarget({ id, judul }); + setShowDeleteModal(true); + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + + try { + const success = await deleteInformasi(deleteTarget.id); + + if (success) { + showNotification('success', 'Informasi berhasil dihapus'); + fetchInformasi(); // Refresh list + } + } catch (err) { + console.error('Error deleting informasi:', err); + showNotification('error', err.message || 'Gagal menghapus informasi'); + } finally { + setShowDeleteModal(false); + setDeleteTarget(null); + } + }; + + const formatDate = (dateString) => { + if (!dateString) return '-'; + const date = new Date(dateString); + return date.toLocaleString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const truncateText = (text, maxLength = 100) => { + if (!text) return ''; + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; + }; + + // Redirect if not super admin + if (!isSuperAdmin) { + return ( + +
+ +

Akses Ditolak

+

Halaman ini hanya dapat diakses oleh Super Admin

+
+
+ ); + } + + return ( + + {/* Notification */} + {notification.show && ( +
+
+ {notification.type === 'success' ? '✓' : '✕'} {notification.message} +
+
+ )} + + {/* Header Section */} +
+
+

Kelola Informasi

+ +
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Loading State */} + {loading ? ( +
+

Memuat data...

+
+ ) : ( + <> + {/* Table */} +
+ + + + + + + + + + + + + + + {informasiList.length === 0 ? ( + + + + ) : ( + informasiList.map((info, index) => ( + + + + + + + + + + + )) + )} + +
IDJudulDeskripsiPriorityTargetStatusTanggalAksi
+ Tidak ada data informasi +
{info.id} +
{info.judul}
+ {info.gambar_url && ( + + 📷 Ada gambar + + )} +
+ {truncateText(info.deskripsi, 80)} + + = 80 ? 'bg-yellow-200 text-yellow-800' : 'bg-gray-200 text-gray-700' + }`}> + {info.priority} + + + + {info.target_role === 'all' ? 'Semua' : + info.target_role === 'mahasiswa' ? 'Mahasiswa' : 'Dosen'} + + + + {info.is_active ? 'Aktif' : 'Nonaktif'} + + + {formatDate(info.created_at)} + + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + + Halaman {currentPage} dari {totalPages} + + + +
+ )} + + {/* Per Page Selector */} +
+ +
+ + )} +
+ + {/* Delete Confirmation Modal */} + {showDeleteModal && ( +
+
+
+
+ +
+

Konfirmasi Hapus

+
+

+ Apakah Anda yakin ingin menghapus informasi "{deleteTarget?.judul}"? +
+ Tindakan ini tidak dapat dibatalkan. +

+
+ + +
+
+
+ )} +
+ ); +}; + +export default Informasi; diff --git a/facerecognition_presensi/Website/src/pages/informasiForm.jsx b/facerecognition_presensi/Website/src/pages/informasiForm.jsx new file mode 100644 index 0000000..6176a4e --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/informasiForm.jsx @@ -0,0 +1,513 @@ +import Swal from "sweetalert2"; +import { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { AlertCircle } from 'lucide-react'; +import { + createInformasi, + updateInformasi, + getInformasiByIdAdmin, + apiUpload, + API_BASE_URL +} from '../utils/apiUtils'; +import { getUser } from '../utils/auth'; +import { navigationItems } from '../navigation/navigation'; +import DashboardLayout from '../layouts/dashboardlayout'; + +const InformasiForm = () => { + const navigate = useNavigate(); + const { id } = useParams(); + const isEditMode = Boolean(id); + const [activeNav, setActiveNav] = useState('informasi'); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [uploadingImage, setUploadingImage] = useState(false); + const [notification, setNotification] = useState({ show: false, type: '', message: '' }); + + // Get current user and check role + const currentUser = getUser() || {}; + const isSuperAdmin = currentUser.role === 'admin' || currentUser.role === 'dosen'; + + // Form data + const [formData, setFormData] = useState({ + judul: '', + deskripsi: '', + gambar_url: '', + priority: 0, + tanggal_mulai: '', + tanggal_selesai: '', + target_role: 'all', + is_active: true + }); + + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(''); + + useEffect(() => { + if (isEditMode) { + fetchInformasiDetail(); + } + }, [id]); + + const showNotification = (type, message) => { + Swal.fire({ + icon: type === 'error' ? 'error' : (type === 'success' ? 'success' : 'info'), + title: type === 'error' ? 'Oops...' : (type === 'success' ? 'Berhasil' : 'Info'), + text: message, + timer: 3000, + showConfirmButton: false + }); + }; + + const fetchInformasiDetail = async () => { + try { + setLoading(true); + const data = await getInformasiByIdAdmin(id); + + setFormData({ + judul: data.judul || '', + deskripsi: data.deskripsi || '', + gambar_url: data.gambar_url || '', + priority: data.priority || 0, + tanggal_mulai: data.tanggal_mulai ? formatDateTimeLocal(data.tanggal_mulai) : '', + tanggal_selesai: data.tanggal_selesai ? formatDateTimeLocal(data.tanggal_selesai) : '', + target_role: data.target_role || 'all', + is_active: data.is_active !== undefined ? data.is_active : true + }); + + if (data.gambar_url) { + setImagePreview(`${API_BASE_URL}${data.gambar_url}`); + } + } catch (err) { + console.error('Error fetching informasi detail:', err); + setError(err.message || 'Gagal memuat data informasi'); + } finally { + setLoading(false); + } + }; + + const formatDateTimeLocal = (isoString) => { + if (!isoString) return ''; + const date = new Date(isoString); + // Format: YYYY-MM-DDTHH:mm + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + }; + + const handleInputChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value + })); + }; + + const handleImageChange = (e) => { + const file = e.target.files[0]; + if (!file) return; + + // Validasi file type + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + showNotification('error', 'Format file tidak didukung. Gunakan JPG, PNG, GIF, atau WebP.'); + return; + } + + // Validasi file size (max 5MB) + if (file.size > 5 * 1024 * 1024) { + showNotification('error', 'Ukuran file terlalu besar. Maksimal 5MB.'); + return; + } + + setImageFile(file); + + // Preview image + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + }; + + const handleUploadImage = async () => { + if (!imageFile) { + showNotification('error', 'Pilih gambar terlebih dahulu'); + return; + } + + try { + setUploadingImage(true); + const formData = new FormData(); + formData.append('file', imageFile); + const result = await apiUpload('/informasi/upload', formData); + + setFormData(prev => ({ + ...prev, + gambar_url: result.url + })); + + showNotification('success', 'Gambar berhasil diupload!'); + setImageFile(null); + } catch (err) { + console.error('Error uploading image:', err); + showNotification('error', err.message || 'Gagal mengupload gambar'); + } finally { + setUploadingImage(false); + } + }; + + const handleRemoveImage = () => { + setFormData(prev => ({ + ...prev, + gambar_url: '' + })); + setImagePreview(''); + setImageFile(null); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + // Validasi + if (!formData.judul.trim()) { + showNotification('error', 'Judul harus diisi'); + return; + } + + if (formData.judul.trim().length < 5) { + showNotification('error', 'Judul minimal 5 karakter'); + return; + } + + if (formData.judul.trim().length > 255) { + showNotification('error', 'Judul maksimal 255 karakter'); + return; + } + + if (!formData.deskripsi.trim()) { + showNotification('error', 'Deskripsi harus diisi'); + return; + } + + if (formData.deskripsi.trim().length < 10) { + showNotification('error', 'Deskripsi minimal 10 karakter'); + return; + } + + if (formData.priority < 0 || formData.priority > 100) { + showNotification('error', 'Priority harus antara 0-100'); + return; + } + + // Validasi tanggal + if (formData.tanggal_mulai && formData.tanggal_selesai) { + const mulai = new Date(formData.tanggal_mulai); + const selesai = new Date(formData.tanggal_selesai); + if (selesai <= mulai) { + showNotification('error', 'Tanggal selesai harus setelah tanggal mulai'); + return; + } + } + + try { + setLoading(true); + setError(null); + + // Prepare data + const submitData = { + judul: formData.judul.trim(), + deskripsi: formData.deskripsi.trim(), + priority: parseInt(formData.priority), + target_role: formData.target_role, + gambar_url: formData.gambar_url || null, + tanggal_mulai: formData.tanggal_mulai || null, + tanggal_selesai: formData.tanggal_selesai || null + }; + + // Tambahkan is_active hanya untuk edit mode + if (isEditMode) { + submitData.is_active = formData.is_active; + } + + // Call API + if (isEditMode) { + await updateInformasi(id, submitData); + showNotification('success', 'Informasi berhasil diupdate!'); + } else { + await createInformasi(submitData); + showNotification('success', 'Informasi berhasil ditambahkan!'); + } + + setTimeout(() => navigate('/informasi'), 1000); + } catch (err) { + console.error('Error saving informasi:', err); + setError(err.message || 'Gagal menyimpan informasi'); + } finally { + setLoading(false); + } + }; + + // Redirect if not super admin + if (!isSuperAdmin) { + return ( + +
+ +

Akses Ditolak

+

Halaman ini hanya dapat diakses oleh Super Admin

+
+
+ ); + } + + return ( + + {/* Notification */} + {notification.show && ( +
+
+ {notification.type === 'success' ? '✓' : '✕'} {notification.message} +
+
+ )} + +
+
+

+ {isEditMode ? 'Edit Informasi' : 'Tambah Informasi'} +

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Judul */} +
+ + +

+ {formData.judul.length}/255 karakter (minimal 5) +

+
+ + {/* Deskripsi */} +
+ + +
+ + )} +
+ +
+ + +
+ + )} + + )} + + {activeTab === "assignment" && ( +
+

Mata Kuliah yang Saya Ampu

+ + {assignments.length === 0 ? ( +
+ +

Belum ada mata kuliah yang diampu

+
+ ) : ( +
+ {assignments.map((assignment) => ( +
+
+
+

+ {assignment.nama_mk} +

+

+ Kode MK: {assignment.kode_mk} +

+
+ + {assignment.nama_kelas} + + + {assignment.prodi} + + + {assignment.tahun_ajaran} + + + Semester {assignment.semester_aktif} + +
+
+ + {assignment.status} + +
+
+ ))} +
+ )} +
+ )} + + {activeTab === "statistik" && ( +
+

Statistik Mengajar

+ +
+
+
+

Total Mata Kuliah

+ +
+

{assignments.length}

+
+ +
+
+

Mata Kuliah Aktif

+ +
+

+ {assignments.filter(a => a.status === 'Aktif').length} +

+
+ +
+
+

Total Kelas

+ +
+

+ {new Set(assignments.map(a => a.id_kelas)).size} +

+
+
+
+ )} +
+
+
+ + ); +} diff --git a/facerecognition_presensi/Website/src/pages/profilSaya_local.jsx b/facerecognition_presensi/Website/src/pages/profilSaya_local.jsx new file mode 100644 index 0000000..a8cda11 --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/profilSaya_local.jsx @@ -0,0 +1,610 @@ +import Swal from "sweetalert2"; +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import DashboardLayout from "../layouts/dashboardlayout"; +import { navigationItems } from "../navigation/navigation"; +import { + User, Mail, Phone, MapPin, Calendar, Award, + Edit2, Save, X, BookOpen, BarChart3 +} from "lucide-react"; +import axios from "axios"; +import { getToken, getUser, setUser } from "../utils/auth"; +import { updateUser } from "../utils/apiUtils"; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; + +export default function ProfilSaya() { + const navigate = useNavigate(); + const [activeNav, setActiveNav] = useState("profil-saya"); + const [activeTab, setActiveTab] = useState("profil"); + const [dosen, setDosen] = useState(null); + const [assignments, setAssignments] = useState([]); + const [loading, setLoading] = useState(true); + const [isEditing, setIsEditing] = useState(false); + const [notification, setNotification] = useState({ show: false, type: '', message: '' }); + + const [editFormData, setEditFormData] = useState({ + nama_dosen: '', + email_dosen: '', + no_hp: '', + tempat_lahir: '', + tanggal_lahir: '', + jenis_kelamin: '', + agama: '', + alamat: '' + }); + + // Get current user + const currentUser = getUser() || {}; + + useEffect(() => { + // Check if user is admin or dosen + if (currentUser.role !== 'admin' && currentUser.role !== 'dosen') { + navigate('/dashboard'); + return; + } + + fetchMyProfile(); + fetchMyAssignments(); + }, []); + + const fetchMyProfile = async () => { + try { + setLoading(true); + const token = getToken(); + if (!token) { + showNotification('error', 'Token otentikasi tidak ditemukan. Silakan login ulang.'); + return; + } + + // Get dosen by user_id + const response = await axios.get(`${API_BASE_URL}/dosen`, { + headers: { Authorization: `Bearer ${token}` } + }); + + // Find current user's dosen data + const myDosen = response.data.find(d => d.id_user === currentUser.id_user); + + if (myDosen) { + setDosen(myDosen); + setEditFormData({ + nama_dosen: myDosen.nama_dosen || '', + email_dosen: myDosen.email_dosen || '', + no_hp: myDosen.no_hp || '', + tempat_lahir: myDosen.tempat_lahir || '', + tanggal_lahir: myDosen.tanggal_lahir || '', + jenis_kelamin: myDosen.jenis_kelamin || '', + agama: myDosen.agama || '', + alamat: myDosen.alamat || '' + }); + } else if (currentUser.role === 'admin') { + setDosen({ + id_dosen: null, + id_user: currentUser.id_user || currentUser.id, + nama_dosen: currentUser.name || currentUser.nama || currentUser.username || 'Super Admin', + email: currentUser.email, + nip: 'SUPER ADMIN', + role: 'admin', + jenis_kelamin: '-', + agama: '-', + alamat: '-', + tempat_lahir: '-', + tanggal_lahir: '', + no_hp: '-' + }); + setEditFormData({ + nama_dosen: currentUser.name || currentUser.nama || currentUser.username || '', + email_dosen: currentUser.email || '', + no_hp: '', + tempat_lahir: '', + tanggal_lahir: '', + jenis_kelamin: '', + agama: '', + alamat: '' + }); + setAssignments([]); + } + } catch (error) { + console.error("Error fetching profile:", error); + showNotification('error', 'Gagal memuat profil'); + } finally { + setLoading(false); + } + }; + + const fetchMyAssignments = async () => { + try { + const token = getToken(); + if (!token) { + showNotification('error', 'Token otentikasi tidak ditemukan. Silakan login ulang.'); + setAssignments([]); + return; + } + + // Get all assignments and filter by current user + const response = await axios.get(`${API_BASE_URL}/kelas-mata-kuliah`, { + headers: { Authorization: `Bearer ${token}` } + }); + + // Filter assignments for current dosen + const myAssignments = response.data.filter(a => a.id_dosen === currentUser.id_dosen); + setAssignments(myAssignments); + } catch (error) { + console.error("Error fetching assignments:", error); + setAssignments([]); + } + }; + + const handleUpdateProfile = async (e) => { + e.preventDefault(); + + try { + const token = getToken(); + if (!token) { + showNotification('error', 'Token otentikasi tidak ditemukan. Silakan login ulang.'); + return; + } + if (dosen.role === 'admin' && !dosen.id_dosen) { + const payload = { + name: editFormData.nama_dosen, + email: editFormData.email_dosen || undefined, + }; + await updateUser(dosen.id_user || currentUser.id_user || currentUser.id, payload); + + const userObj = getUser(); + if (userObj) { + userObj.name = payload.name; + userObj.email = payload.email || userObj.email; + setUser(userObj, true); + } + } else { + await axios.put( + `${API_BASE_URL}/users/dosen/${currentUser.id_user || dosen.id_user}`, + editFormData, + { headers: { Authorization: `Bearer ${token}` } } + ); + } + + showNotification('success', 'Profil berhasil diupdate'); + setIsEditing(false); + fetchMyProfile(); + } catch (error) { + console.error("Error updating profile:", error); + showNotification('error', error.response?.data?.detail || 'Gagal update profil'); + } + }; + + const showNotification = (type, message) => { + Swal.fire({ + icon: type === 'error' ? 'error' : (type === 'success' ? 'success' : 'info'), + title: type === 'error' ? 'Oops...' : (type === 'success' ? 'Berhasil' : 'Info'), + text: message, + timer: 3000, + showConfirmButton: false + }); + }; + + const formatDate = (dateString) => { + if (!dateString) return '-'; + const date = new Date(dateString); + return date.toLocaleDateString('id-ID', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); + }; + + if (loading) { + return ( + +
+
+

Loading...

+
+
+ ); + } + + if (!dosen) { + return ( + +
+

Data profil tidak ditemukan

+
+
+ ); + } + + return ( + + {/* Notification */} + {notification.show && ( +
+
+ {notification.type === 'success' ? '✓' : '✕'} {notification.message} +
+
+ )} + +
+ {/* Header Card */} +
+
+
+
+ +
+
+

{dosen.nama_dosen}

+

NIP: {dosen.nip}

+
+ + {dosen.jenis_kelamin === 'L' ? 'Laki-laki' : 'Perempuan'} + + + {assignments.length} Mata Kuliah + +
+
+
+ {!isEditing && activeTab === "profil" && ( + + )} +
+
+ + {/* Tabs */} +
+
+
+ + + +
+
+ + {/* Tab Content */} +
+ {activeTab === "profil" && ( + <> + {!isEditing ? ( +
+

Informasi Pribadi

+ +
+
+
+ +
+
+

NIP

+

{dosen.nip}

+
+
+ +
+
+ +
+
+

Email

+

{dosen.email_dosen || dosen.email || '-'}

+
+
+ +
+
+ +
+
+

No HP

+

{dosen.no_hp || '-'}

+
+
+ +
+
+ +
+
+

Tempat, Tanggal Lahir

+

+ {dosen.tempat_lahir || '-'}, {formatDate(dosen.tanggal_lahir)} +

+
+
+ +
+
+ +
+
+

Jenis Kelamin

+

+ {dosen.jenis_kelamin === 'L' ? 'Laki-laki' : 'Perempuan'} +

+
+
+ +
+
+ +
+
+

Agama

+

{dosen.agama || '-'}

+
+
+ +
+
+ +
+
+

Alamat

+

{dosen.alamat || '-'}

+
+
+
+
+ ) : ( +
+
+

Edit Profil

+ +
+ +
+
+ + setEditFormData({...editFormData, nama_dosen: e.target.value})} + className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" + required + /> +
+ +
+ + setEditFormData({...editFormData, email_dosen: e.target.value})} + className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" + required + /> +
+ + {dosen.id_dosen && ( + <> +
+ + setEditFormData({...editFormData, no_hp: e.target.value})} + className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setEditFormData({...editFormData, tempat_lahir: e.target.value})} + className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setEditFormData({...editFormData, tanggal_lahir: e.target.value})} + className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + +
+ +
+ + +
+ + )} +
+ +
+ + +
+
+ )} + + )} + + {activeTab === "assignment" && ( +
+

Mata Kuliah yang Saya Ampu

+ + {assignments.length === 0 ? ( +
+ +

Belum ada mata kuliah yang diampu

+
+ ) : ( +
+ {assignments.map((assignment) => ( +
+
+
+

+ {assignment.nama_mk} +

+

+ Kode MK: {assignment.kode_mk} +

+
+ + {assignment.nama_kelas} + + + {assignment.prodi} + + + {assignment.tahun_ajaran} + + + Semester {assignment.semester_aktif} + +
+
+ + {assignment.status} + +
+
+ ))} +
+ )} +
+ )} + + {activeTab === "statistik" && ( +
+

Statistik Mengajar

+ +
+
+
+

Total Mata Kuliah

+ +
+

{assignments.length}

+
+ +
+
+

Mata Kuliah Aktif

+ +
+

+ {assignments.filter(a => a.status === 'Aktif').length} +

+
+ +
+
+

Total Kelas

+ +
+

+ {new Set(assignments.map(a => a.id_kelas)).size} +

+
+
+
+ )} +
+
+
+
+ ); +} diff --git a/facerecognition_presensi/Website/src/pages/user.jsx b/facerecognition_presensi/Website/src/pages/user.jsx new file mode 100644 index 0000000..1e965cf --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/user.jsx @@ -0,0 +1,1097 @@ +import Swal from "sweetalert2"; +// src/pages/user.jsx +import { useState, useEffect, useRef } from "react"; +import DashboardLayout from "../layouts/dashboardlayout"; +import { navigationItems } from "../navigation/navigation"; +import { + getUserList, createUser, updateUser, deleteUser, + getProdiList, getAngkatanList, getGolonganList, +} from "../utils/apiUtils"; +import { getUser } from "../utils/auth"; +import { + UserPlus, Edit, Trash2, Users, GraduationCap, Shield, BookOpen, + AlertCircle, Upload, Download, FileSpreadsheet, X, CheckCircle2, XCircle, +} from "lucide-react"; +import * as XLSX from "xlsx"; + +export default function UserPage() { + const [activeNav, setActiveNav] = useState("User"); + const [activeTab, setActiveTab] = useState("Semua"); + const [users, setUsers] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [formMode, setFormMode] = useState("add"); + const [isLoading, setIsLoading] = useState(false); + const [loggedUser, setLoggedUser] = useState(null); + const [notification, setNotification] = useState({ show: false, type: '', message: '' }); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [selectedUsers, setSelectedUsers] = useState([]); + + // Excel import states + const [showImportModal, setShowImportModal] = useState(false); + const [importData, setImportData] = useState([]); + const [importErrors, setImportErrors] = useState([]); + const [importResults, setImportResults] = useState([]); + const [isImporting, setIsImporting] = useState(false); + const [importDone, setImportDone] = useState(false); + const fileInputRef = useRef(null); + + // Master data dropdowns for mahasiswa + const [prodiList, setProdiList] = useState([]); + const [angkatanList, setAngkatanList] = useState([]); + const [golonganList, setGolonganList] = useState([]); + + const [currentUser, setCurrentUser] = useState({ + id_user: null, nama: "", email: "", role: "mahasiswa", + nidn: "", nim: "", password: "", + id_prodi: null, id_angkatan: null, id_golongan: null, + }); + + useEffect(() => { + const stored = getUser(); + if (stored) setLoggedUser(stored); + fetchUsers(); + fetchMasterData(); + }, []); + + useEffect(() => { + if (isModalOpen || showImportModal) document.body.style.overflow = 'hidden'; + else document.body.style.overflow = 'unset'; + return () => { document.body.style.overflow = 'unset'; }; + }, [isModalOpen, showImportModal]); + + const fetchUsers = async () => { + try { + setIsLoading(true); + const data = await getUserList(); + setUsers(data || []); + } catch (error) { + showNotification('error', "Gagal memuat data pengguna: " + error.message); + } finally { + setIsLoading(false); + } + }; + + const fetchMasterData = async () => { + try { + const [prodi, angkatan, golongan] = await Promise.all([ + getProdiList(), getAngkatanList(), getGolonganList(), + ]); + setProdiList(prodi || []); + setAngkatanList(angkatan || []); + setGolonganList(golongan || []); + } catch (e) { + console.error("Gagal memuat master data:", e); + } + }; + + const showNotification = (type, message) => { + Swal.fire({ + icon: type === 'error' ? 'error' : (type === 'success' ? 'success' : 'info'), + title: type === 'error' ? 'Oops...' : (type === 'success' ? 'Berhasil' : 'Info'), + text: message, + timer: 3000, + showConfirmButton: false + }); + }; + + const roleLabel = (role) => ({ admin: 'Admin', dosen: 'Dosen', mahasiswa: 'Mahasiswa' }[role] || role); + + const filteredUsers = activeTab === "Semua" ? users + : users.filter((u) => u.role === activeTab.toLowerCase()); + + const openAddModal = () => { + setFormMode("add"); + setCurrentUser({ + id_user: null, nama: "", email: "", role: "mahasiswa", + nidn: "", nim: "", password: "", + id_prodi: null, id_angkatan: null, id_golongan: null, + }); + setIsModalOpen(true); + }; + + const openEditModal = (user) => { + setFormMode("edit"); + setCurrentUser({ + id_user: user.id_user, + nama: user.nama || "", + email: user.email || "", + role: user.role, + nidn: user.nip || "", + nim: user.nim || "", + password: "", + id_prodi: user.id_prodi || null, + id_angkatan: user.id_angkatan || null, + id_golongan: user.id_golongan || null, + }); + setIsModalOpen(true); + }; + + const handleDeleteClick = (id, nama) => { + setDeleteTarget({ id, nama }); + setShowDeleteModal(true); + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + try { + setIsLoading(true); + await deleteUser(deleteTarget.id); + showNotification('success', "Pengguna berhasil dihapus"); + setSelectedUsers(selectedUsers.filter(id => id !== deleteTarget.id)); + fetchUsers(); + } catch (error) { + showNotification('error', "Gagal menghapus: " + error.message); + } finally { + setIsLoading(false); + setShowDeleteModal(false); + setDeleteTarget(null); + } + }; + + const toggleSelect = (id) => { + if (selectedUsers.includes(id)) { + setSelectedUsers(selectedUsers.filter(item => item !== id)); + } else { + setSelectedUsers([...selectedUsers, id]); + } + }; + + const toggleSelectAll = () => { + if (selectedUsers.length === filteredUsers.length && filteredUsers.length > 0) { + setSelectedUsers([]); + } else { + setSelectedUsers(filteredUsers.map(u => u.id_user)); + } + }; + + const handleBulkDelete = async () => { + if (selectedUsers.length === 0) return; + if (!window.confirm(`Yakin ingin menghapus ${selectedUsers.length} pengguna terpilih?`)) return; + + setIsLoading(true); + try { + await Promise.all(selectedUsers.map(id => deleteUser(id))); + showNotification("success", `${selectedUsers.length} pengguna berhasil dihapus`); + setSelectedUsers([]); + fetchUsers(); + } catch (err) { + showNotification("error", "Gagal menghapus sebagian data: " + err.message); + fetchUsers(); + } finally { + setIsLoading(false); + } + }; + + // ===== DUPLICATE CHECK HELPERS ===== + const isDuplicateEmail = (email, excludeId = null) => + users.some(u => u.email?.toLowerCase() === email.toLowerCase() && u.id_user !== excludeId); + + const isDuplicateNim = (nim, excludeId = null) => + users.some(u => u.nim && u.nim === nim.trim() && u.id_user !== excludeId); + + const isDuplicateNip = (nip, excludeId = null) => + users.some(u => u.nip && u.nip === nip.trim() && u.id_user !== excludeId); + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + + if (!currentUser.nama.trim()) { showNotification('error', "Nama wajib diisi"); setIsLoading(false); return; } + if (!currentUser.email.trim()) { showNotification('error', "Email wajib diisi"); setIsLoading(false); return; } + if (formMode === "add" && !currentUser.password.trim()) { showNotification('error', "Password wajib diisi"); setIsLoading(false); return; } + + // --- Duplicate checks (manual add & edit) --- + const excludeId = formMode === "edit" ? currentUser.id_user : null; + if (isDuplicateEmail(currentUser.email, excludeId)) { + showNotification('error', `Email "${currentUser.email}" sudah terdaftar di sistem`); + setIsLoading(false); return; + } + if ((currentUser.role === "dosen" || currentUser.role === "admin") && currentUser.nidn.trim()) { + if (isDuplicateNip(currentUser.nidn, excludeId)) { + showNotification('error', `NIP "${currentUser.nidn}" sudah terdaftar di sistem`); + setIsLoading(false); return; + } + } + if (currentUser.role === "mahasiswa" && currentUser.nim.trim()) { + if (isDuplicateNim(currentUser.nim, excludeId)) { + showNotification('error', `NIM "${currentUser.nim}" sudah terdaftar di sistem`); + setIsLoading(false); return; + } + } + + try { + if (formMode === "add") { + const payload = { + name: currentUser.nama, + email: currentUser.email, + password: currentUser.password, + role: currentUser.role, + }; + if (currentUser.role === "dosen" || currentUser.role === "admin") { + if (currentUser.role === "dosen" && !currentUser.nidn.trim()) { showNotification('error', "NIP wajib diisi untuk Dosen"); setIsLoading(false); return; } + if (currentUser.nidn.trim()) payload.nidn = currentUser.nidn; + payload.nama_dosen = currentUser.nama; + } else if (currentUser.role === "mahasiswa") { + if (!currentUser.nim.trim()) { showNotification('error', "NIM wajib diisi"); setIsLoading(false); return; } + if (!currentUser.id_prodi) { showNotification('error', "Prodi wajib dipilih"); setIsLoading(false); return; } + if (!currentUser.id_angkatan) { showNotification('error', "Angkatan wajib dipilih"); setIsLoading(false); return; } + if (!currentUser.id_golongan) { showNotification('error', "Golongan wajib dipilih"); setIsLoading(false); return; } + payload.nim = currentUser.nim; + payload.id_prodi = parseInt(currentUser.id_prodi); + payload.id_angkatan = parseInt(currentUser.id_angkatan); + payload.id_golongan = parseInt(currentUser.id_golongan); + } + await createUser(payload); + showNotification('success', "Pengguna berhasil ditambahkan"); + } else { + const payload = { name: currentUser.nama, email: currentUser.email }; + if (currentUser.role === "dosen" || currentUser.role === "admin") { + if (currentUser.nidn) payload.nidn = currentUser.nidn; + payload.nama_dosen = currentUser.nama; + } else if (currentUser.role === "mahasiswa") { + if (currentUser.nim) payload.nim = currentUser.nim; + if (currentUser.id_prodi) payload.id_prodi = parseInt(currentUser.id_prodi); + if (currentUser.id_angkatan) payload.id_angkatan = parseInt(currentUser.id_angkatan); + if (currentUser.id_golongan) payload.id_golongan = parseInt(currentUser.id_golongan); + } + await updateUser(currentUser.id_user, payload); + showNotification('success', "Pengguna berhasil diperbarui"); + } + setIsModalOpen(false); + fetchUsers(); + } catch (error) { + showNotification('error', "Gagal: " + error.message); + } finally { + setIsLoading(false); + } + }; + + // ===== EXCEL IMPORT FUNCTIONS ===== + + const downloadTemplate = () => { + // Sheet 1: Mahasiswa + const mahasiswaHeaders = [ + ["role", "nama", "email", "password", "nim", "id_prodi", "id_angkatan", "id_golongan"] + ]; + const mahasiswaExample = [ + ["mahasiswa", "Budi Santoso", "budi@email.com", "password123", "20210001", 1, 1, 1], + ["mahasiswa", "Siti Rahayu", "siti@email.com", "password123", "20210002", 1, 1, 2], + ]; + const wsMahasiswa = XLSX.utils.aoa_to_sheet([...mahasiswaHeaders, ...mahasiswaExample]); + + // Sheet 2: Dosen + const dosenHeaders = [ + ["role", "nama", "email", "password", "nip"] + ]; + const dosenExample = [ + ["dosen", "Dr. Ahmad Fauzi", "ahmad@email.com", "password123", "19800101200501001"], + ["dosen", "Prof. Dewi Lestari", "dewi@email.com", "password123", "19750515200012002"], + ]; + const wsDosen = XLSX.utils.aoa_to_sheet([...dosenHeaders, ...dosenExample]); + + // Sheet 3: Admin + const adminHeaders = [ + ["role", "nama", "email", "password", "nip"] + ]; + const adminExample = [ + ["admin", "Admin Sistem", "admin2@email.com", "password123", "19900101201001001"], + ]; + const wsAdmin = XLSX.utils.aoa_to_sheet([...adminHeaders, ...adminExample]); + + // Sheet 4: Referensi + const refProdi = [["=== DATA PRODI ==="], ["id_prodi", "kode_prodi", "nama_prodi"]]; + prodiList.forEach(p => refProdi.push([p.id_prodi, p.kode_prodi, p.nama_prodi])); + + const refAngkatan = [[], ["=== DATA ANGKATAN ==="], ["id_angkatan", "tahun_angkatan"]]; + angkatanList.forEach(a => refAngkatan.push([a.id_angkatan, a.tahun_angkatan])); + + const refGolongan = [[], ["=== DATA GOLONGAN ==="], ["id_golongan", "nama_golongan"]]; + golonganList.forEach(g => refGolongan.push([g.id_golongan, g.nama_golongan])); + + const wsRef = XLSX.utils.aoa_to_sheet([...refProdi, ...refAngkatan, ...refGolongan]); + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, wsMahasiswa, "Mahasiswa"); + XLSX.utils.book_append_sheet(wb, wsDosen, "Dosen"); + XLSX.utils.book_append_sheet(wb, wsAdmin, "Admin"); + XLSX.utils.book_append_sheet(wb, wsRef, "Referensi ID"); + + XLSX.writeFile(wb, "template_import_pengguna.xlsx"); + }; + + const exportToExcel = () => { + const toExport = activeTab === "Semua" ? users : filteredUsers; + + const mahasiswaData = toExport.filter(u => u.role === 'mahasiswa').map(u => ({ + role: 'mahasiswa', nama: u.nama, email: u.email, password: "", nim: u.nim, + id_prodi: u.id_prodi, id_angkatan: u.id_angkatan, id_golongan: u.id_golongan + })); + + const dosenData = toExport.filter(u => u.role === 'dosen').map(u => ({ + role: 'dosen', nama: u.nama, email: u.email, password: "", nip: u.nip + })); + + const adminData = toExport.filter(u => u.role === 'admin').map(u => ({ + role: 'admin', nama: u.nama, email: u.email, password: "", nip: u.nip + })); + + const wb = XLSX.utils.book_new(); + + if (mahasiswaData.length > 0) { + const wsMahasiswa = XLSX.utils.json_to_sheet(mahasiswaData); + XLSX.utils.book_append_sheet(wb, wsMahasiswa, "Mahasiswa"); + } + if (dosenData.length > 0) { + const wsDosen = XLSX.utils.json_to_sheet(dosenData); + XLSX.utils.book_append_sheet(wb, wsDosen, "Dosen"); + } + if (adminData.length > 0) { + const wsAdmin = XLSX.utils.json_to_sheet(adminData); + XLSX.utils.book_append_sheet(wb, wsAdmin, "Admin"); + } + + const refProdi = [["=== DATA PRODI ==="], ["id_prodi", "kode_prodi", "nama_prodi"]]; + prodiList.forEach(p => refProdi.push([p.id_prodi, p.kode_prodi, p.nama_prodi])); + const refAngkatan = [[], ["=== DATA ANGKATAN ==="], ["id_angkatan", "tahun_angkatan"]]; + angkatanList.forEach(a => refAngkatan.push([a.id_angkatan, a.tahun_angkatan])); + const refGolongan = [[], ["=== DATA GOLONGAN ==="], ["id_golongan", "nama_golongan"]]; + golonganList.forEach(g => refGolongan.push([g.id_golongan, g.nama_golongan])); + const wsRef = XLSX.utils.aoa_to_sheet([...refProdi, ...refAngkatan, ...refGolongan]); + XLSX.utils.book_append_sheet(wb, wsRef, "Referensi ID"); + + XLSX.writeFile(wb, "Data_Pengguna_Export.xlsx"); + }; + + const handleFileUpload = (e) => { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (evt) => { + try { + const currentUsers = users; // snapshot of existing users + const seenEmails = new Set(); + const seenNims = new Set(); + const seenNips = new Set(); + const wb = XLSX.read(evt.target.result, { type: 'binary' }); + const parsedRows = []; + const errors = []; + + // Process all sheets (Mahasiswa, Dosen, Admin) + const validSheets = ["Mahasiswa", "Dosen", "Admin"]; + wb.SheetNames.forEach(sheetName => { + if (!validSheets.includes(sheetName)) return; + + const ws = wb.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json(ws, { defval: "" }); + + // Track emails/NIMs/NIPs already seen in THIS file to catch intra-file duplicates + rows.forEach((row, idx) => { + const rowNum = idx + 2; // +2 because row 1 is header + const role = (row.role || "").toString().toLowerCase().trim(); + + if (!role) return; // skip empty rows + + if (!["mahasiswa", "dosen", "admin"].includes(role)) { + errors.push(`Sheet "${sheetName}", Baris ${rowNum}: Role "${role}" tidak valid`); + return; + } + + const nama = (row.nama || "").toString().trim(); + const email = (row.email || "").toString().trim(); + const password = (row.password || "").toString().trim(); + + if (!nama) { errors.push(`Sheet "${sheetName}", Baris ${rowNum}: Nama wajib diisi`); return; } + if (!email) { errors.push(`Sheet "${sheetName}", Baris ${rowNum}: Email wajib diisi`); return; } + + const entry = { role, nama, email, password, _sheet: sheetName, _row: rowNum, _skip: false, _skipReason: '', _action: 'insert' }; + let nim = "", nip = ""; + + if (role === "mahasiswa") { + nim = (row.nim || "").toString().trim(); + const id_prodi = row.id_prodi; + const id_angkatan = row.id_angkatan; + const id_golongan = row.id_golongan; + + if (!nim) { errors.push(`Sheet "${sheetName}", Baris ${rowNum}: NIM wajib diisi untuk mahasiswa`); return; } + if (!id_prodi) { errors.push(`Sheet "${sheetName}", Baris ${rowNum}: id_prodi wajib diisi`); return; } + if (!id_angkatan) { errors.push(`Sheet "${sheetName}", Baris ${rowNum}: id_angkatan wajib diisi`); return; } + if (!id_golongan) { errors.push(`Sheet "${sheetName}", Baris ${rowNum}: id_golongan wajib diisi`); return; } + + entry.nim = nim; + entry.id_prodi = parseInt(id_prodi); + entry.id_angkatan = parseInt(id_angkatan); + entry.id_golongan = parseInt(id_golongan); + } else { + nip = (row.nip || row.nidn || "").toString().trim(); + if (role === "dosen" && !nip) { errors.push(`Sheet "${sheetName}", Baris ${rowNum}: NIP wajib diisi untuk dosen`); return; } + entry.nip = nip; + } + + // Temukan user yang sudah ada + let existingUser = null; + if (role === "mahasiswa") existingUser = currentUsers.find(u => String(u.nim).toLowerCase() === nim.toLowerCase()); + else existingUser = currentUsers.find(u => String(u.nip || "").toLowerCase() === nip.toLowerCase()); + + if (!existingUser) existingUser = currentUsers.find(u => String(u.email).toLowerCase() === email.toLowerCase()); + + if (existingUser) { + entry._action = 'update'; + entry._id = existingUser.id_user; + + if (currentUsers.some(u => String(u.email).toLowerCase() === email.toLowerCase() && u.id_user !== existingUser.id_user)) { + entry._skip = true; + entry._skipReason = `Email "${email}" sudah dipakai user lain`; + } + if (existingUser.role !== role) { + entry._skip = true; + entry._skipReason = `Role tidak bisa diubah dari ${existingUser.role} ke ${role}`; + } + + if (!entry._skip) { + const pwChanged = password && password !== ""; + let changed = false; + if (pwChanged || existingUser.nama !== nama || String(existingUser.email) !== email) changed = true; + if (role === "mahasiswa") { + if (String(existingUser.nim) !== nim || existingUser.id_prodi !== entry.id_prodi || existingUser.id_angkatan !== entry.id_angkatan || existingUser.id_golongan !== entry.id_golongan) changed = true; + } else { + if (String(existingUser.nip || existingUser.nidn || "") !== nip) changed = true; + } + + if (!changed) { + entry._skip = true; + entry._skipReason = "Data sama, tidak ada perubahan"; + } + } + } else { + if (!password) { errors.push(`Sheet "${sheetName}", Baris ${rowNum}: Password wajib diisi untuk data baru`); return; } + if (role === "mahasiswa" && seenNims.has(nim)) { + entry._skip = true; entry._skipReason = `NIM "${nim}" duplikat dalam file`; + } + if (role !== "mahasiswa" && seenNips.has(nip)) { + entry._skip = true; entry._skipReason = `NIP "${nip}" duplikat dalam file`; + } + if (seenEmails.has(email.toLowerCase())) { + entry._skip = true; entry._skipReason = `Email "${email}" duplikat dalam file`; + } + } + + if (!entry._skip && entry._action === 'insert') { + if (nim) seenNims.add(nim); + if (nip) seenNips.add(nip); + seenEmails.add(email.toLowerCase()); + } + + parsedRows.push(entry); + }); + }); + + setImportData(parsedRows); + setImportErrors(errors); + setImportResults([]); + setImportDone(false); + } catch (err) { + setImportErrors([`Gagal membaca file: ${err.message}`]); + setImportData([]); + } + }; + reader.readAsBinaryString(file); + + // Reset file input + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const handleImportSubmit = async () => { + if (importData.length === 0) return; + + setIsImporting(true); + const results = []; + + for (const row of importData) { + // Skip rows that were flagged as duplicates during file parsing + if (row._skip) { + results.push({ nama: row.nama, email: row.email, role: row.role, status: 'skipped', message: row._skipReason }); + continue; + } + + try { + const payload = { + name: row.nama, + email: row.email, + role: row.role, + }; + if (row.password) payload.password = row.password; + + if (row.role === "dosen" || row.role === "admin") { + if (row.nip) payload.nidn = row.nip; + payload.nama_dosen = row.nama; + } else if (row.role === "mahasiswa") { + payload.nim = row.nim; + payload.id_prodi = row.id_prodi; + payload.id_angkatan = row.id_angkatan; + payload.id_golongan = row.id_golongan; + } + + if (row._action === 'update') { + await updateUser(row._id, payload); + results.push({ nama: row.nama, email: row.email, role: row.role, status: 'success', message: 'Berhasil diperbarui' }); + } else { + await createUser(payload); + results.push({ nama: row.nama, email: row.email, role: row.role, status: 'success', message: 'Berhasil ditambahkan' }); + } + } catch (err) { + results.push({ nama: row.nama, email: row.email, role: row.role, status: 'error', message: err.message }); + } + } + + setImportResults(results); + setImportDone(true); + setIsImporting(false); + + const successCount = results.filter(r => r.status === 'success' && r.message.includes('ditambahkan')).length; + const updateCount = results.filter(r => r.status === 'success' && r.message.includes('diperbarui')).length; + const skippedCount = results.filter(r => r.status === 'skipped').length; + if (successCount > 0 || updateCount > 0) { + fetchUsers(); + } + if (successCount > 0 || updateCount > 0 || skippedCount > 0) { + showNotification( + 'info', + `Selesai! Baru: ${successCount}, Diperbarui: ${updateCount}, Dilewati: ${skippedCount}` + ); + } + }; + + const closeImportModal = () => { + setShowImportModal(false); + setImportData([]); + setImportErrors([]); + setImportResults([]); + setImportDone(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const countByRole = (role) => users.filter((u) => u.role === role).length; + const tabs = [ + { name: "Semua", icon: Users, count: users.length }, + { name: "Admin", icon: Shield, count: countByRole("admin") }, + { name: "Dosen", icon: BookOpen, count: countByRole("dosen") }, + { name: "Mahasiswa", icon: GraduationCap, count: countByRole("mahasiswa") }, + ]; + + return ( + + {notification.show && ( +
+
+ {notification.type === 'success' ? '✓' : '✕'} {notification.message} +
+
+ )} + +
+
+

+ Manajemen Pengguna +

+ {loggedUser?.role === "admin" && ( +
+ {selectedUsers.length > 0 && ( + + )} + {/* Export Excel */} + + {/* Download Template */} + + {/* Import Excel */} + + {/* Add Single User */} + +
+ )} +
+ + {/* Tabs */} +
+ {tabs.map((tab) => { + const Icon = tab.icon; + return ( + + ); + })} +
+ +
+ + + + + + + + + + + + + + + {isLoading ? ( + + ) : filteredUsers.length > 0 ? ( + filteredUsers.map((user, i) => ( + + + + + + + + + + + )) + ) : ( + + )} + +
+ 0 && selectedUsers.length === filteredUsers.length} + onChange={toggleSelectAll} /> + NoNamaEmailRoleNIP/NIMKelasAksi
+
+

Memuat data...

+
+ toggleSelect(user.id_user)} /> + {i + 1}{user.nama}{user.email} + + {roleLabel(user.role)} + + + {user.role === "mahasiswa" ? (user.nim || '-') : (user.nip || '-')} + {user.nama_kelas || '-'} + {loggedUser?.role === "admin" ? ( +
+ + +
+ ) : ( + View Only + )} +
+ +

Belum ada data pengguna {activeTab !== "Semua" ? activeTab : ""}

+
+
+
+ + {/* ===== IMPORT EXCEL MODAL ===== */} + {showImportModal && ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Import Pengguna dari Excel

+

Upload banyak pengguna sekaligus

+
+
+ +
+ +
+ {/* Step 1: Download Template */} + {!importDone && ( +
+

+ 1 + Download Template Excel +

+

+ Unduh template terlebih dahulu. Template berisi sheet untuk Mahasiswa, Dosen, dan Admin. + Sheet Referensi ID berisi daftar ID Prodi, Angkatan, dan Golongan yang tersedia di sistem. +

+ +
+ )} + + {/* Step 2: Upload File */} + {!importDone && ( +
+

+ 2 + Upload File Excel +

+

Isi template lalu upload file .xlsx di sini

+ +
+ )} + + {/* Validation Errors */} + {importErrors.length > 0 && ( +
+

+ Error Validasi ({importErrors.length} masalah) +

+
    + {importErrors.map((err, i) => ( +
  • + • {err} +
  • + ))} +
+
+ )} + + {/* Preview Data */} + {importData.length > 0 && !importDone && (() => { + const readyCount = importData.filter(r => !r._skip).length; + const skipCount = importData.filter(r => r._skip).length; + return ( +
+

+ 3 + Preview Data + ({readyCount} akan diimport + {skipCount > 0 && , {skipCount} dilewati duplikat)} + {skipCount === 0 && )} +

+
+ + + + + + + + + + + + {importData.map((row, i) => ( + + + + + + + + ))} + +
NamaEmailRoleNIP/NIMStatus
{row.nama}{row.email} + + {row.role} + + {row.nim || row.nip || '-'} + {row._skip + ? + ⚠ Dilewati + + : row._action === 'update' + ? + ✎ Diperbarui + + : + ✓ Baru + + } +
+
+ {importData.some(r => r._skip) && ( +

+ ⚠ Baris berwarna kuning sudah ada di sistem dan akan dilewati saat import. +

+ )} +
+ ); + })()} + + {/* Import Results */} + {importDone && importResults.length > 0 && ( +
+
+
+ + + {importResults.filter(r => r.status === 'success').length} berhasil + + {importResults.filter(r => r.status === 'skipped').length > 0 && ( + + ⚠ {importResults.filter(r => r.status === 'skipped').length} dilewati (duplikat) + + )} + {importResults.filter(r => r.status === 'error').length > 0 && ( + + + {importResults.filter(r => r.status === 'error').length} gagal + + )} +
+
+
+ + + + + + + + + + + + {importResults.map((r, i) => ( + + + + + + + + ))} + +
NamaEmailRoleStatusKeterangan
{r.nama}{r.email} + + {r.role} + + + {r.status === 'success' + ? Berhasil + : r.status === 'skipped' + ? ⚠ Dilewati + : Gagal + } + {r.message}
+
+
+ )} +
+ + {/* Footer Buttons */} +
+ + {!importDone && importData.length > 0 && importErrors.length === 0 && (() => { + const readyCount = importData.filter(r => !r._skip).length; + return readyCount > 0 ? ( + + ) : null; + })()} +
+
+
+ )} + + {/* Modal Form Single User */} + {isModalOpen && ( +
+
+

+ {formMode === "add" ? "Tambah Pengguna" : "Edit Pengguna"} +

+
+
+ + setCurrentUser({ ...currentUser, nama: e.target.value })} + className="w-full border border-gray-300 rounded-lg p-2 mt-1 focus:ring-2 focus:ring-blue-500 outline-none" disabled={isLoading} /> +
+ +
+ + setCurrentUser({ ...currentUser, email: e.target.value })} + className="w-full border border-gray-300 rounded-lg p-2 mt-1 focus:ring-2 focus:ring-blue-500 outline-none" disabled={isLoading} /> +
+ +
+ + setCurrentUser({ ...currentUser, password: e.target.value })} + placeholder={formMode === "edit" ? "Kosongkan jika tidak diubah" : ""} + className="w-full border border-gray-300 rounded-lg p-2 mt-1 focus:ring-2 focus:ring-blue-500 outline-none" disabled={isLoading} /> +
+ +
+ + +
+ + {/* Dosen/Admin fields */} + {(currentUser.role === "dosen" || currentUser.role === "admin") && ( +
+ + setCurrentUser({ ...currentUser, nidn: e.target.value })} + className="w-full border border-gray-300 rounded-lg p-2 mt-1 focus:ring-2 focus:ring-blue-500 outline-none" disabled={isLoading} + placeholder="Nomor Induk Pegawai" /> +
+ )} + + {/* Mahasiswa fields */} + {currentUser.role === "mahasiswa" && ( + <> +
+ + setCurrentUser({ ...currentUser, nim: e.target.value })} + className="w-full border border-gray-300 rounded-lg p-2 mt-1 focus:ring-2 focus:ring-blue-500 outline-none" disabled={isLoading} + placeholder="Nomor Induk Mahasiswa" /> +
+
+ + +
+
+ + +
+
+ + +
+ + )} + +
+ + +
+
+
+
+ )} + + {/* Delete Modal */} + {showDeleteModal && ( +
+
+
+
+

Konfirmasi Hapus

+
+

+ Hapus pengguna "{deleteTarget?.nama}"? + Tindakan ini tidak dapat dibatalkan. +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/facerecognition_presensi/Website/src/pages/viewMinggu.css b/facerecognition_presensi/Website/src/pages/viewMinggu.css new file mode 100644 index 0000000..a469304 --- /dev/null +++ b/facerecognition_presensi/Website/src/pages/viewMinggu.css @@ -0,0 +1,103 @@ +/* src/pages/viewMinggu.css */ + +/* Animation untuk notification slide in dari kanan */ +@keyframes slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.animate-slide-in { + animation: slide-in 0.3s ease-out; +} + +/* Smooth transition untuk progress bar */ +.progress-bar-transition { + transition: width 0.5s ease-in-out; +} + +/* Hover effect untuk card minggu */ +.minggu-card { + transition: all 0.2s ease-in-out; +} + +.minggu-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); +} + +/* Animation untuk modal */ +@keyframes modal-appear { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.modal-content { + animation: modal-appear 0.2s ease-out; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .status-summary { + padding: 1rem; + } + + .status-number { + font-size: 2rem; + } +} + +/* Loading spinner animation */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +/* Fade in animation untuk list item */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.materi-item { + animation: fade-in 0.3s ease-out; +} + +/* Pulse effect untuk badge */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +.badge-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} \ No newline at end of file diff --git a/facerecognition_presensi/Website/src/utils/apiUtils.js b/facerecognition_presensi/Website/src/utils/apiUtils.js new file mode 100644 index 0000000..6d46af3 --- /dev/null +++ b/facerecognition_presensi/Website/src/utils/apiUtils.js @@ -0,0 +1,252 @@ +// utils/apiUtils.js + +import { getToken, isTokenExpired, forceLogout } from './auth'; + +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'; + +export const apiRequest = async (endpoint, options = {}) => { + const token = getToken(); + + // Check token expiry before sending request + if (token && isTokenExpired()) { + forceLogout(); + throw new Error('Sesi telah berakhir, silakan login kembali'); + } + + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + // Remove Content-Type if it's FormData to let browser set boundary + if (options.body instanceof FormData) { + delete headers['Content-Type']; + } + + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + }); + + // Handle 401 Unauthorized — session expired or invalid token + if (response.status === 401) { + forceLogout(); + throw new Error('Sesi telah berakhir, silakan login kembali'); + } + + if (!response.ok) { + let errorData = {}; + try { errorData = await response.json(); } catch(e) {} + throw new Error(errorData.detail || `API request failed (${response.status})`); + } + + if (response.status === 204) { + return null; + } + + return await response.json(); + } catch (error) { + throw error; + } +}; + +export const apiGet = (endpoint) => apiRequest(endpoint, { method: 'GET' }); +export const apiPost = (endpoint, data) => apiRequest(endpoint, { method: 'POST', body: JSON.stringify(data) }); +export const apiPut = (endpoint, data) => apiRequest(endpoint, { method: 'PUT', body: JSON.stringify(data) }); +export const apiDelete = (endpoint) => apiRequest(endpoint, { method: 'DELETE' }); +export const apiUpload = (endpoint, formData, method = 'POST') => apiRequest(endpoint, { method, body: formData }); + +// ===== AUTH ===== +export const login = async (username, password) => { + const formData = new URLSearchParams(); + formData.append('username', username); + formData.append('password', password); + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString() + }); + if (!response.ok) { + const error = await response.json().catch(()=>({})); + throw new Error(error.detail || 'Login gagal'); + } + return await response.json(); +}; + +// ===== DASHBOARD ===== +export const getDashboardData = () => apiGet('/dashboard/'); + +// ===== MATA KULIAH (backend: /matakuliah/) ===== +export const getMataKuliahList = () => apiGet('/matakuliah/'); +export const getMataKuliahById = (id) => apiGet(`/matakuliah/${id}`); +export const createMataKuliah = (data) => apiPost('/matakuliah/', data); +export const updateMataKuliah = (id, data) => apiPut(`/matakuliah/${id}`, data); +export const deleteMataKuliah = (id) => apiDelete(`/matakuliah/${id}`); + +// ===== KELAS MATA KULIAH (composite via Jadwal / compat_routes) ===== +export const getKelasMKList = () => apiGet('/kelas-mata-kuliah/'); +export const getKelasMKById = (id) => apiGet(`/kelas-mata-kuliah/${id}`); +export const getKelasMKByDosen = (id_dosen) => apiGet(`/kelas-mata-kuliah/dosen/${id_dosen}`); +export const getKelasMKMe = () => apiGet('/kelas-mata-kuliah/me'); +export const updateKelasMK = (id, data) => apiPut(`/kelas-mata-kuliah/${id}`, data); +export const deleteKelasMK = (id) => apiDelete(`/kelas-mata-kuliah/${id}`); + +// ===== KELAS (standalone) ===== +export const getKelasList = (params = {}) => { + const query = new URLSearchParams(params).toString(); + return apiGet(`/kelas/${query ? '?' + query : ''}`); +}; +export const getKelasById = (id) => apiGet(`/kelas/${id}`); +export const createKelas = (data) => apiPost('/kelas/', data); +export const updateKelas = (id, data) => apiPut(`/kelas/${id}`, data); +export const deleteKelas = (id) => apiDelete(`/kelas/${id}`); + +// ===== DOSEN ===== +export const getDosenList = () => apiGet('/dosen/'); +export const getDosenById = (id) => apiGet(`/dosen/${id}`); +export const createDosen = (data) => apiPost('/dosen/', data); +export const updateDosen = (id, data) => apiPut(`/dosen/${id}`, data); +export const deleteDosen = (id) => apiDelete(`/dosen/${id}`); + +// ===== MAHASISWA ===== +export const getMahasiswaList = (params = {}) => { + const query = new URLSearchParams(params).toString(); + return apiGet(`/mahasiswa/${query ? '?' + query : ''}`); +}; +export const getMahasiswaById = (id) => apiGet(`/mahasiswa/${id}`); +export const createMahasiswa = (data) => apiPost('/mahasiswa/', data); +export const bulkCreateMahasiswa = (data) => apiPost('/mahasiswa/bulk', data); +export const updateMahasiswa = (id, data) => apiPut(`/mahasiswa/${id}`, data); +export const deleteMahasiswa = (id) => apiDelete(`/mahasiswa/${id}`); +export const bulkDeleteMahasiswa = (data) => apiPost('/mahasiswa/bulk-delete', data); + +// ===== USER (composite - backend compat_routes) ===== +export const getUserList = () => apiGet('/users/'); +export const getUserById = (id) => apiGet(`/users/${id}`); +export const createUser = (data) => apiPost('/users/', data); +export const updateUser = (id, data) => apiPut(`/users/${id}`, data); +export const deleteUser = (id) => apiDelete(`/users/${id}`); + +// ===== JADWAL KULIAH ===== +export const getJadwalKuliahList = (params = {}) => { + const query = new URLSearchParams(params).toString(); + return apiGet(`/jadwal/${query ? '?' + query : ''}`); +}; +export const getJadwalKuliahById = (id) => apiGet(`/jadwal/${id}`); +export const createJadwalKuliah = (data) => apiPost('/jadwal/', data); +export const createJadwalFromMatakuliah = (data) => apiPost('/jadwal/from-matakuliah', data); +export const updateJadwalKuliah = (id, data) => apiPut(`/jadwal/${id}`, data); +export const deleteJadwalKuliah = (id) => apiDelete(`/jadwal/${id}`); + + +// ===== PERTEMUAN ===== +export const getPertemuanList = (params = {}) => { + const query = new URLSearchParams(params).toString(); + return apiGet(`/pertemuan/${query ? '?' + query : ''}`); +}; +export const getPertemuanById = (id) => apiGet(`/pertemuan/${id}`); +export const createPertemuan = (data) => apiPost('/pertemuan/', data); +export const updatePertemuan = (id, data) => apiPut(`/pertemuan/${id}`, data); +export const deletePertemuan = (id) => apiDelete(`/pertemuan/${id}`); +export const mulaiPertemuan = (id, durasi = 15) => apiPost(`/pertemuan/${id}/mulai?durasi_menit=${durasi}`, {}); +export const selesaiPertemuan = (id) => apiPost(`/pertemuan/${id}/selesai`, {}); +export const batalkanPertemuan = (id) => apiPost(`/pertemuan/${id}/batalkan`, {}); + +// ===== PRESENSI ===== +export const getPresensiList = (params = {}) => { + const query = new URLSearchParams(params).toString(); + return apiGet(`/presensi/${query ? '?' + query : ''}`); +}; +export const getPresensiById = (id) => apiGet(`/presensi/${id}`); +export const createPresensiManual = (data) => apiPost('/presensi/', data); +export const updatePresensi = (id, data) => apiPut(`/presensi/${id}`, data); +export const deletePresensi = (id) => apiDelete(`/presensi/${id}`); +export const absensiFaceRecognition = (data) => apiPost('/presensi/face-recognition', data); +export const getRekapPertemuan = (pertemuan_id) => apiGet(`/presensi/rekap/pertemuan/${pertemuan_id}`); +export const getRekapMahasiswa = (mahasiswa_id, jadwal_id) => apiGet(`/presensi/rekap/mahasiswa/${mahasiswa_id}/jadwal/${jadwal_id}`); + +// ===== DATASET WAJAH ===== +export const getDatasetWajahList = (params = {}) => { + const query = new URLSearchParams(params).toString(); + return apiGet(`/dataset-wajah/${query ? '?' + query : ''}`); +}; +export const uploadDatasetWajah = (mahasiswa_id, formData) => apiUpload(`/dataset-wajah/upload/${mahasiswa_id}`, formData); +export const getDatasetCount = (mahasiswa_id) => apiGet(`/dataset-wajah/mahasiswa/${mahasiswa_id}/count`); + +// ===== REGISTRASI WAJAH ===== +export const getRegistrasiList = () => apiGet('/registrasi-wajah/'); +export const createRegistrasi = (data) => apiPost('/registrasi-wajah/', data); + +// ===== LOG DETEKSI ===== +export const getLogDeteksiList = () => apiGet('/log-deteksi/'); +export const getStatistikLog = () => apiGet('/log-deteksi/statistik'); + +// ===== MASTER DATA ===== +export const getProdiList = () => apiGet('/prodi/'); +export const createProdi = (data) => apiPost('/prodi/', data); +export const updateProdi = (id, data) => apiPut(`/prodi/${id}`, data); +export const deleteProdi = (id) => apiDelete(`/prodi/${id}`); + +export const getAngkatanList = () => apiGet('/angkatan/'); +export const createAngkatan = (data) => apiPost('/angkatan/', data); +export const updateAngkatan = (id, data) => apiPut(`/angkatan/${id}`, data); +export const deleteAngkatan = (id) => apiDelete(`/angkatan/${id}`); + +export const getGolonganList = () => apiGet('/golongan/'); +export const createGolongan = (data) => apiPost('/golongan/', data); +export const updateGolongan = (id, data) => apiPut(`/golongan/${id}`, data); +export const deleteGolongan = (id) => apiDelete(`/golongan/${id}`); + +// ===== PROFIL ===== +export const getProfilSaya = () => apiGet('/auth/me'); +export const updateProfilSaya = (data) => apiPut('/auth/me', data); +export const changePassword = (data) => apiPost('/auth/change-password', data); + +// ===== PENAWARAN MATA KULIAH ===== +export const getPenawaranMKList = (params = {}) => { + const query = new URLSearchParams(params).toString(); + return apiGet(`/penawaran-matakuliah/${query ? '?' + query : ''}`); +}; +export const getPenawaranMKById = (id) => apiGet(`/penawaran-matakuliah/${id}`); +export const createPenawaranMK = (data) => apiPost('/penawaran-matakuliah/', data); +export const updatePenawaranMK = (id, data) => apiPut(`/penawaran-matakuliah/${id}`, data); +export const deletePenawaranMK = (id) => apiDelete(`/penawaran-matakuliah/${id}`); + +// ===== PENAWARAN KELAS ===== +export const getKelasByPenawaran = (id_penawaran) => apiGet(`/penawaran-kelas/penawaran/${id_penawaran}`); +export const addKelasToPenawaran = (data) => { + // Jika data berisi id_kelas langsung, gunakan endpoint from-kelas + if (data.id_kelas) { + return apiPost('/penawaran-kelas/from-kelas', { id_penawaran: data.id_penawaran, id_kelas: data.id_kelas }); + } + return apiPost('/penawaran-kelas/', data); +}; +export const removeKelasFromPenawaran = (kelas_id) => apiDelete(`/penawaran-kelas/${kelas_id}`); + +// ===== PENGAJAR ===== +export const getPengajarByPenawaran = (id_penawaran) => apiGet(`/pengajar/penawaran/${id_penawaran}`); +export const addPengajar = (data) => apiPost('/pengajar/', data); +export const updatePengajarRole = (pengajar_id, data) => apiPut(`/pengajar/${pengajar_id}`, data); +export const removePengajar = (pengajar_id) => apiDelete(`/pengajar/${pengajar_id}`); + +// ===== PENGAJAR PERTEMUAN ===== +export const getPengajarPertemuan = (id_pertemuan) => apiGet(`/pengajar-pertemuan/pertemuan/${id_pertemuan}`); +export const addPengajarPertemuan = (data) => apiPost('/pengajar-pertemuan/', data); +export const removePengajarPertemuan = (id) => apiDelete(`/pengajar-pertemuan/${id}`); + +// ===== GEOLOKASI ===== +export const getGeolokasiList = (params = {}) => { + const query = new URLSearchParams(params).toString(); + return apiGet(`/geolokasi/${query ? '?' + query : ''}`); +}; +export const getGeolokasiById = (id) => apiGet(`/geolokasi/${id}`); +export const createGeolokasi = (data) => apiPost('/geolokasi/', data); +export const updateGeolokasi = (id, data) => apiPut(`/geolokasi/${id}`, data); +export const deleteGeolokasi = (id) => apiDelete(`/geolokasi/${id}`); \ No newline at end of file diff --git a/facerecognition_presensi/Website/src/utils/auth.js b/facerecognition_presensi/Website/src/utils/auth.js new file mode 100644 index 0000000..4671945 --- /dev/null +++ b/facerecognition_presensi/Website/src/utils/auth.js @@ -0,0 +1,114 @@ +// src/utils/auth.js + +// Keys used in storage +const TOKEN_KEY = 'token'; +const USER_KEY = 'user'; +const ROLE_KEY = 'role'; +const USERNAME_KEY = 'username'; + +// --- Exported Functions --- + +export const getToken = () => { + return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY); +}; + +export const setToken = (token, remember = false) => { + if (remember) { + localStorage.setItem(TOKEN_KEY, token); + } else { + sessionStorage.setItem(TOKEN_KEY, token); + } +}; + +export const removeToken = () => { + localStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(TOKEN_KEY); +}; + +export const getUser = () => { + const userStr = localStorage.getItem(USER_KEY) || sessionStorage.getItem(USER_KEY); + if (!userStr) return null; + try { + return JSON.parse(userStr); + } catch (e) { + return null; + } +}; + +export const setUser = (user, remember = false) => { + const userStr = JSON.stringify(user); + if (remember) { + localStorage.setItem(USER_KEY, userStr); + if (user.role) localStorage.setItem(ROLE_KEY, user.role); + if (user.name) localStorage.setItem(USERNAME_KEY, user.name); + } else { + sessionStorage.setItem(USER_KEY, userStr); + if (user.role) sessionStorage.setItem(ROLE_KEY, user.role); + if (user.name) sessionStorage.setItem(USERNAME_KEY, user.name); + } +}; + +export const getRole = () => { + return localStorage.getItem(ROLE_KEY) || sessionStorage.getItem(ROLE_KEY); +}; + +export const setRole = (role, remember = false) => { + if (remember) { + localStorage.setItem(ROLE_KEY, role); + } else { + sessionStorage.setItem(ROLE_KEY, role); + } +}; + +export const getUsername = () => { + return localStorage.getItem(USERNAME_KEY) || sessionStorage.getItem(USERNAME_KEY); +}; + +export const setUsername = (username, remember = false) => { + if (remember) { + localStorage.setItem(USERNAME_KEY, username); + } else { + sessionStorage.setItem(USERNAME_KEY, username); + } +}; + +export const clearAuth = () => { + removeToken(); + localStorage.removeItem(USER_KEY); + sessionStorage.removeItem(USER_KEY); + localStorage.removeItem(ROLE_KEY); + sessionStorage.removeItem(ROLE_KEY); + localStorage.removeItem(USERNAME_KEY); + sessionStorage.removeItem(USERNAME_KEY); +}; + +export const isAuthenticated = () => { + const token = getToken(); + return !!token && !isTokenExpired(); +}; + +// Check if JWT token is expired +export const isTokenExpired = () => { + const token = getToken(); + if (!token) return true; + + try { + // JWT payload is the second segment (base64url encoded) + const payload = JSON.parse(atob(token.split('.')[1])); + if (!payload.exp) return false; // No exp claim, assume not expired + // exp is in seconds, Date.now() is in milliseconds — add 5s buffer + return payload.exp * 1000 < Date.now() + 5000; + } catch (e) { + // If token can't be decoded, treat as expired + return true; + } +}; + +// Force logout: clear all auth data and redirect to login +export const forceLogout = () => { + clearAuth(); + // Only redirect if not already on the login page + if (!window.location.pathname.includes('/login')) { + window.location.href = '/login'; + } +}; \ No newline at end of file diff --git a/facerecognition_presensi/Website/tailwind.config.js b/facerecognition_presensi/Website/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/facerecognition_presensi/Website/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/facerecognition_presensi/Website/vite.config.js b/facerecognition_presensi/Website/vite.config.js new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/facerecognition_presensi/Website/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/facerecognition_presensi/backend/.env.example b/facerecognition_presensi/backend/.env.example new file mode 100644 index 0000000..2cc1544 --- /dev/null +++ b/facerecognition_presensi/backend/.env.example @@ -0,0 +1,16 @@ +DB_HOST=localhost +DB_PORT=3306 +DB_USER=presensi_app +DB_PASSWORD=GANTI_PASSWORD_DATABASE +DB_NAME=skripsi_presensi +# Ganti dengan hasil: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=GANTI_DENGAN_KUNCI_ACAK_MINIMAL_32_KARAKTER +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +APP_NAME="Face Recognition Attendance API" +APP_VERSION=2.0.0 +DEBUG=false +FACE_MODEL_TYPE=enhanced +PRELOAD_ML_MODEL=false +UPLOAD_DIR=uploads +DATASET_DIR=datasets diff --git a/facerecognition_presensi/backend/app/config/__init__.py b/facerecognition_presensi/backend/app/config/__init__.py new file mode 100644 index 0000000..315a92c --- /dev/null +++ b/facerecognition_presensi/backend/app/config/__init__.py @@ -0,0 +1,4 @@ +from app.config.database import Base, engine, SessionLocal, get_db +from app.config.settings import settings, get_settings + +__all__ = ["Base", "engine", "SessionLocal", "get_db", "settings", "get_settings"] diff --git a/facerecognition_presensi/backend/app/config/database.py b/facerecognition_presensi/backend/app/config/database.py new file mode 100644 index 0000000..94a6183 --- /dev/null +++ b/facerecognition_presensi/backend/app/config/database.py @@ -0,0 +1,26 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from app.config.settings import settings + + +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_recycle=300, + echo=settings.DEBUG, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + """Dependency: yields a SQLAlchemy DB session and closes it after use.""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/facerecognition_presensi/backend/app/config/settings.py b/facerecognition_presensi/backend/app/config/settings.py new file mode 100644 index 0000000..92ca4c9 --- /dev/null +++ b/facerecognition_presensi/backend/app/config/settings.py @@ -0,0 +1,68 @@ +from pydantic_settings import BaseSettings +from pydantic import Field, field_validator +from sqlalchemy.engine import URL +from functools import lru_cache +from pathlib import Path + + +class Settings(BaseSettings): + # Database + DB_HOST: str = "localhost" + DB_PORT: int = 3306 + DB_USER: str = "root" + DB_PASSWORD: str = "" + DB_NAME: str = "skripsi_presensi" + + # JWT + SECRET_KEY: str = Field(..., min_length=32) + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 + + # App + APP_NAME: str = "Face Recognition Attendance API" + APP_VERSION: str = "2.0.0" + DEBUG: bool = False + + # ML - Path Configuration + # ModelBaru folder location (4 parent levels: config -> app -> backend -> root) + MODELBARU_DIR: Path = Path(__file__).resolve().parent.parent.parent.parent / "ModelBaru" + MODELS_DIR: Path = MODELBARU_DIR / "models" + FACE_MAHASISWA_DIR: Path = MODELBARU_DIR / "face_mahasiswa" + + # ML + PRELOAD_ML_MODEL: bool = False + # Model type: "classification", "arcface", atau "enhanced" + # Enhanced = Score-Level Fusion (ModelBaru) - default + FACE_MODEL_TYPE: str = "enhanced" + + # File Paths + UPLOAD_DIR: str = "uploads" + DATASET_DIR: str = "datasets" + + @field_validator("SECRET_KEY") + @classmethod + def validate_secret(cls, value: str) -> str: + if value.startswith("GANTI_"): + raise ValueError("Ganti SECRET_KEY contoh dengan kunci acak sendiri") + return value + + @property + def DATABASE_URL(self) -> str: + return URL.create( + "mysql+pymysql", username=self.DB_USER, password=self.DB_PASSWORD, + host=self.DB_HOST, port=self.DB_PORT, database=self.DB_NAME, + query={"charset": "utf8mb4"}, + ).render_as_string(hide_password=False) + + class Config: + env_file = str(Path(__file__).resolve().parents[2] / ".env") + env_file_encoding = "utf-8" + extra = "ignore" + + +@lru_cache() +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/facerecognition_presensi/backend/app/controllers/dosen_controller.py b/facerecognition_presensi/backend/app/controllers/dosen_controller.py new file mode 100644 index 0000000..e69de29 diff --git a/facerecognition_presensi/backend/app/controllers/face_controller.py b/facerecognition_presensi/backend/app/controllers/face_controller.py new file mode 100644 index 0000000..e69de29 diff --git a/facerecognition_presensi/backend/app/controllers/geolokasi_controller.py b/facerecognition_presensi/backend/app/controllers/geolokasi_controller.py new file mode 100644 index 0000000..f0112ca --- /dev/null +++ b/facerecognition_presensi/backend/app/controllers/geolokasi_controller.py @@ -0,0 +1,99 @@ +import math +from fastapi import HTTPException +from sqlalchemy.orm import Session +from app.models.geolokasi import Geolokasi, StatusGeolokasiEnum +from app.schemas.geolokasi import GeolokasiCreate, GeolokasiUpdate + + +# ─── HAVERSINE ──────────────────────────────────────────────────────────────── + +def hitung_jarak_haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """ + Menghitung jarak antara dua titik koordinat (lat/long) menggunakan + formula Haversine. Mengembalikan jarak dalam meter. + """ + R = 6_371_000 # Radius bumi dalam meter + phi1 = math.radians(lat1) + phi2 = math.radians(lat2) + d_phi = math.radians(lat2 - lat1) + d_lambda = math.radians(lon2 - lon1) + + a = ( + math.sin(d_phi / 2) ** 2 + + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2 + ) + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + return R * c # jarak dalam meter + + +def validasi_lokasi( + lat_mahasiswa: float, + lon_mahasiswa: float, + geolokasi: Geolokasi, +) -> float: + """ + Validasi apakah koordinat mahasiswa ada di dalam radius lokasi. + Mengembalikan jarak (meter) jika valid. + Raise HTTPException 403 jika di luar radius. + """ + jarak = hitung_jarak_haversine( + lat_mahasiswa, lon_mahasiswa, + float(geolokasi.latitude), float(geolokasi.longitude), + ) + if jarak > geolokasi.radius_meter: + raise HTTPException( + status_code=403, + detail=( + f"Lokasi Anda terlalu jauh dari area presensi '{geolokasi.nama_lokasi}'. " + f"Jarak Anda: {jarak:.0f} m, Radius maksimum: {geolokasi.radius_meter} m." + ), + ) + return jarak + + +# ─── CRUD GEOLOKASI ─────────────────────────────────────────────────────────── + +def get_all_geolokasi(db: Session, status_filter: str | None = None, skip: int = 0, limit: int = 100): + q = db.query(Geolokasi) + if status_filter: + q = q.filter(Geolokasi.status == status_filter) + return q.order_by(Geolokasi.nama_lokasi).offset(skip).limit(limit).all() + + +def get_geolokasi_by_id(db: Session, geolokasi_id: int) -> Geolokasi: + g = db.query(Geolokasi).filter(Geolokasi.id_geolokasi == geolokasi_id).first() + if not g: + raise HTTPException(404, "Geolokasi tidak ditemukan") + return g + + +def create_geolokasi(db: Session, payload: GeolokasiCreate) -> Geolokasi: + g = Geolokasi(**payload.model_dump()) + db.add(g) + db.commit() + db.refresh(g) + return g + + +def update_geolokasi(db: Session, geolokasi_id: int, payload: GeolokasiUpdate) -> Geolokasi: + g = get_geolokasi_by_id(db, geolokasi_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(g, k, v) + db.commit() + db.refresh(g) + return g + + +def delete_geolokasi(db: Session, geolokasi_id: int): + g = get_geolokasi_by_id(db, geolokasi_id) + # Cek apakah masih digunakan oleh pertemuan + from app.models.pertemuan import Pertemuan + used = db.query(Pertemuan).filter(Pertemuan.id_geolokasi == geolokasi_id).count() + if used > 0: + raise HTTPException( + 400, + f"Lokasi ini masih digunakan oleh {used} pertemuan. " + "Ubah pertemuan tersebut ke mode bebas terlebih dahulu." + ) + db.delete(g) + db.commit() diff --git a/facerecognition_presensi/backend/app/controllers/jadwal_controller.py b/facerecognition_presensi/backend/app/controllers/jadwal_controller.py new file mode 100644 index 0000000..4a1b14c --- /dev/null +++ b/facerecognition_presensi/backend/app/controllers/jadwal_controller.py @@ -0,0 +1,1008 @@ +from datetime import datetime, timedelta, timezone + +from app.models.jadwal import HariEnum, Jadwal, StatusJadwalEnum +from app.models.penawaran_matakuliah import PenawaranMatakuliah, Pengajar +from app.models.pertemuan import Pertemuan, StatusPertemuanEnum +from app.schemas.jadwal import ( + JadwalCreate, + JadwalUpdate, + PertemuanCreate, + PertemuanUpdate, +) +from fastapi import HTTPException +from sqlalchemy.orm import Session, joinedload + + +def _create_presensi_belum(db, id_pertemuan: int, id_penawaran: int): + """Create belum-presensi records for every student enrolled in this penawaran.""" + from app.models.mahasiswa import Mahasiswa + from app.models.penawaran_matakuliah import PenawaranKelas + from app.models.presensi import Presensi + from app.models.presensi import StatusPresensiEnum as SP + + seen: set[int] = set() + for kelas in ( + db.query(PenawaranKelas) + .filter(PenawaranKelas.id_penawaran == id_penawaran) + .all() + ): + for mhs in ( + db.query(Mahasiswa) + .filter( + Mahasiswa.id_prodi == kelas.id_prodi, + Mahasiswa.id_angkatan == kelas.id_angkatan, + Mahasiswa.id_golongan == kelas.id_golongan, + ) + .all() + ): + if mhs.id_mahasiswa in seen: + continue + seen.add(mhs.id_mahasiswa) + exists = ( + db.query(Presensi) + .filter( + Presensi.id_pertemuan == id_pertemuan, + Presensi.id_mahasiswa == mhs.id_mahasiswa, + ) + .first() + ) + if not exists: + db.add( + Presensi( + id_pertemuan=id_pertemuan, + id_mahasiswa=mhs.id_mahasiswa, + status_presensi=SP.belum, + ) + ) + + +def get_presensi_per_pertemuan(db, pertemuan_id: int): + """Return pertemuan info + full student attendance list.""" + from app.models.mahasiswa import Mahasiswa + from app.models.matakuliah import MataKuliah + from app.models.penawaran_matakuliah import PenawaranMatakuliah + from app.models.presensi import Presensi + + pertemuan = ( + db.query(Pertemuan).filter(Pertemuan.id_pertemuan == pertemuan_id).first() + ) + if not pertemuan: + raise HTTPException(404, "Pertemuan tidak ditemukan") + + jadwal = db.query(Jadwal).filter(Jadwal.id_jadwal == pertemuan.id_jadwal).first() + penawaran = ( + db.query(PenawaranMatakuliah) + .filter(PenawaranMatakuliah.id_penawaran == jadwal.id_penawaran) + .first() + if jadwal + else None + ) + mk = ( + db.query(MataKuliah) + .filter(MataKuliah.id_matakuliah == penawaran.id_matakuliah) + .first() + if penawaran + else None + ) + + records = ( + db.query(Presensi, Mahasiswa) + .join(Mahasiswa, Presensi.id_mahasiswa == Mahasiswa.id_mahasiswa) + .filter(Presensi.id_pertemuan == pertemuan_id) + .order_by(Mahasiswa.nama_mahasiswa) + .all() + ) + + mahasiswa_list = [ + { + "id_presensi": p.id_presensi, + "id_mahasiswa": m.id_mahasiswa, + "nim": m.nim, + "nama_mahasiswa": m.nama_mahasiswa, + "status_presensi": p.status_presensi.value, + "waktu_presensi": p.waktu_presensi, + } + for p, m in records + ] + + total = len(mahasiswa_list) + sudah_hadir = sum( + 1 for s in mahasiswa_list if s["status_presensi"] in ("hadir", "terlambat") + ) + belum = sum(1 for s in mahasiswa_list if s["status_presensi"] == "belum") + + return { + "id_pertemuan": pertemuan.id_pertemuan, + "id_jadwal": pertemuan.id_jadwal, + "pertemuan_ke": pertemuan.pertemuan_ke, + "tanggal": str(pertemuan.tanggal), + "status_pertemuan": pertemuan.status_pertemuan.value, + "nama_matakuliah": mk.nama_matakuliah if mk else None, + "kode_matakuliah": mk.kode_matakuliah if mk else None, + "total_mahasiswa": total, + "sudah_hadir": sudah_hadir, + "belum_presensi": belum, + "mahasiswa_list": mahasiswa_list, + } + + +def _jadwal_to_dict(j: Jadwal) -> dict: + p = j.penawaran + return { + "id_jadwal": j.id_jadwal, + "id_penawaran": j.id_penawaran, + "hari": j.hari.value if j.hari else None, + "jam_mulai": str(j.jam_mulai) if j.jam_mulai else None, + "jam_selesai": str(j.jam_selesai) if j.jam_selesai else None, + "ruangan": j.ruangan, + "status": j.status.value if j.status else "aktif", + # Derived from penawaran + "id_matakuliah": p.id_matakuliah if p else None, + "kode_matakuliah": p.matakuliah.kode_matakuliah if p and p.matakuliah else None, + "nama_matakuliah": p.matakuliah.nama_matakuliah if p and p.matakuliah else None, + "semester": p.semester if p else None, + "tahun_ajaran": p.tahun_ajaran if p else None, + # Kelas & Pengajar from penawaran + "kelas_list": [ + { + "id": k.id, + "id_prodi": k.id_prodi, + "id_angkatan": k.id_angkatan, + "id_golongan": k.id_golongan, + "kode_prodi": k.prodi.kode_prodi if k.prodi else None, + "tahun_angkatan": k.angkatan.tahun_angkatan if k.angkatan else None, + "nama_golongan": k.golongan.nama_golongan if k.golongan else None, + "nama_kelas": ( + f"{k.prodi.kode_prodi}-{k.angkatan.tahun_angkatan}-{k.golongan.nama_golongan}" + if k.prodi and k.angkatan and k.golongan + else None + ), + } + for k in (p.penawaran_kelas_list if p else []) + ], + "pengajar_list": [ + { + "id_pengajar": g.id_pengajar, + "id_dosen": g.id_dosen, + "role": g.role.value if g.role else "anggota", + "nama_dosen": g.dosen.nama_dosen if g.dosen else None, + "nidn": g.dosen.nidn if g.dosen else None, + } + for g in (p.pengajar_list if p else []) + ], + } + + +def _load_jadwal(db: Session, jadwal_id: int) -> Jadwal: + from app.models.angkatan import Angkatan + from app.models.dosen import Dosen + from app.models.golongan import Golongan + from app.models.matakuliah import MataKuliah + from app.models.penawaran_matakuliah import PenawaranKelas, Pengajar + from app.models.prodi import Prodi + + j = ( + db.query(Jadwal) + .options( + joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.matakuliah), + joinedload(Jadwal.penawaran) + .joinedload(PenawaranMatakuliah.penawaran_kelas_list) + .joinedload(PenawaranKelas.prodi), + joinedload(Jadwal.penawaran) + .joinedload(PenawaranMatakuliah.penawaran_kelas_list) + .joinedload(PenawaranKelas.angkatan), + joinedload(Jadwal.penawaran) + .joinedload(PenawaranMatakuliah.penawaran_kelas_list) + .joinedload(PenawaranKelas.golongan), + joinedload(Jadwal.penawaran) + .joinedload(PenawaranMatakuliah.pengajar_list) + .joinedload(Pengajar.dosen), + ) + .filter(Jadwal.id_jadwal == jadwal_id) + .first() + ) + if not j: + raise HTTPException(404, "Jadwal tidak ditemukan") + return j + + +def get_all_jadwal( + db, id_penawaran=None, hari=None, status_filter=None, skip=0, limit=100 +): + from app.models.penawaran_matakuliah import PenawaranKelas, Pengajar + + q = db.query(Jadwal).options( + joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.matakuliah), + joinedload(Jadwal.penawaran) + .joinedload(PenawaranMatakuliah.penawaran_kelas_list) + .joinedload(PenawaranKelas.prodi), + joinedload(Jadwal.penawaran) + .joinedload(PenawaranMatakuliah.penawaran_kelas_list) + .joinedload(PenawaranKelas.angkatan), + joinedload(Jadwal.penawaran) + .joinedload(PenawaranMatakuliah.penawaran_kelas_list) + .joinedload(PenawaranKelas.golongan), + joinedload(Jadwal.penawaran) + .joinedload(PenawaranMatakuliah.pengajar_list) + .joinedload(Pengajar.dosen), + ) + if id_penawaran: + q = q.filter(Jadwal.id_penawaran == id_penawaran) + if hari: + q = q.filter(Jadwal.hari == hari) + if status_filter: + q = q.filter(Jadwal.status == status_filter) + return [_jadwal_to_dict(j) for j in q.offset(skip).limit(limit).all()] + + +def get_jadwal_by_id(db, jadwal_id: int): + return _jadwal_to_dict(_load_jadwal(db, jadwal_id)) + + +def create_jadwal(db, payload: JadwalCreate): + if ( + not db.query(PenawaranMatakuliah) + .filter(PenawaranMatakuliah.id_penawaran == payload.id_penawaran) + .first() + ): + raise HTTPException(404, "Penawaran mata kuliah tidak ditemukan") + j = Jadwal(**payload.model_dump()) + db.add(j) + db.commit() + db.refresh(j) + return _jadwal_to_dict(_load_jadwal(db, j.id_jadwal)) + + +def update_jadwal(db, jadwal_id: int, payload: JadwalUpdate): + j = db.query(Jadwal).filter(Jadwal.id_jadwal == jadwal_id).first() + if not j: + raise HTTPException(404, "Jadwal tidak ditemukan") + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(j, k, v) + db.commit() + return _jadwal_to_dict(_load_jadwal(db, jadwal_id)) + + +def delete_jadwal(db, jadwal_id: int): + from app.models.presensi import Presensi + from app.models.history_presensi import HistoryPresensi + + j = db.query(Jadwal).filter(Jadwal.id_jadwal == jadwal_id).first() + if not j: + raise HTTPException(404, "Jadwal tidak ditemukan") + + try: + # Ambil semua pertemuan yang terkait dengan jadwal ini + pertemuan_list = ( + db.query(Pertemuan).filter(Pertemuan.id_jadwal == jadwal_id).all() + ) + pertemuan_ids = [p.id_pertemuan for p in pertemuan_list] + + if pertemuan_ids: + # 1. Hapus history_presensi terlebih dahulu (FK ke pertemuan) + db.query(HistoryPresensi).filter( + HistoryPresensi.id_pertemuan.in_(pertemuan_ids) + ).delete(synchronize_session="fetch") + + # 2. Hapus presensi (FK ke pertemuan) + db.query(Presensi).filter( + Presensi.id_pertemuan.in_(pertemuan_ids) + ).delete(synchronize_session="fetch") + + # 3. Hapus semua pertemuan + db.query(Pertemuan).filter( + Pertemuan.id_jadwal == jadwal_id + ).delete(synchronize_session="fetch") + + # 4. Hapus jadwal + db.delete(j) + db.commit() + except Exception as e: + db.rollback() + raise HTTPException(500, f"Gagal menghapus jadwal: {str(e)}") + + + +def create_jadwal_from_matakuliah(db: Session, payload): + """ + Buat jadwal langsung dari mata kuliah. + Otomatis cari/buat penawaran berdasarkan id_matakuliah + semester + tahun_ajaran, + lalu buat jadwal baru. Jika id_kelas disertakan, langsung tambahkan kelas ke penawaran. + """ + from app.models.angkatan import Angkatan + from app.models.golongan import Golongan + from app.models.kelas import Kelas + from app.models.matakuliah import MataKuliah + from app.models.penawaran_matakuliah import PenawaranKelas, PenawaranMatakuliah + + # Validasi mata kuliah + mk = ( + db.query(MataKuliah) + .filter(MataKuliah.id_matakuliah == payload.id_matakuliah) + .first() + ) + if not mk: + raise HTTPException(404, "Mata kuliah tidak ditemukan") + + # Cari atau buat penawaran + penawaran = ( + db.query(PenawaranMatakuliah) + .filter( + PenawaranMatakuliah.id_matakuliah == payload.id_matakuliah, + PenawaranMatakuliah.semester == payload.semester, + PenawaranMatakuliah.tahun_ajaran == payload.tahun_ajaran, + ) + .first() + ) + if not penawaran: + penawaran = PenawaranMatakuliah( + id_matakuliah=payload.id_matakuliah, + semester=payload.semester, + tahun_ajaran=payload.tahun_ajaran, + status="aktif", + ) + db.add(penawaran) + db.flush() + + # Jika id_kelas diberikan, tambahkan kelas ke penawaran + if getattr(payload, "id_kelas", None): + kelas = db.query(Kelas).filter(Kelas.id == payload.id_kelas).first() + if not kelas: + raise HTTPException( + 404, f"Kelas dengan id {payload.id_kelas} tidak ditemukan" + ) + + # Cari id_angkatan dari tahun angkatan kelas + ang_obj = None + if kelas.angkatan: + ang_obj = ( + db.query(Angkatan) + .filter(Angkatan.tahun_angkatan == kelas.angkatan) + .first() + ) + + # Cari id_golongan dari nama golongan kelas + gol_obj = None + if kelas.golongan: + gol_obj = ( + db.query(Golongan) + .filter(Golongan.nama_golongan == kelas.golongan) + .first() + ) + + if ang_obj and gol_obj: + # Cek apakah kelas sudah ada di penawaran ini + existing_kelas = ( + db.query(PenawaranKelas) + .filter( + PenawaranKelas.id_penawaran == penawaran.id_penawaran, + PenawaranKelas.id_prodi == kelas.id_prodi, + PenawaranKelas.id_angkatan == ang_obj.id_angkatan, + PenawaranKelas.id_golongan == gol_obj.id_golongan, + ) + .first() + ) + if not existing_kelas: + pk = PenawaranKelas( + id_penawaran=penawaran.id_penawaran, + id_prodi=kelas.id_prodi, + id_angkatan=ang_obj.id_angkatan, + id_golongan=gol_obj.id_golongan, + ) + db.add(pk) + db.flush() + + # Buat jadwal + j = Jadwal( + id_penawaran=penawaran.id_penawaran, + hari=payload.hari, + jam_mulai=payload.jam_mulai, + jam_selesai=payload.jam_selesai, + ruangan=payload.ruangan, + status=StatusJadwalEnum.aktif, + ) + db.add(j) + db.commit() + db.refresh(j) + return _jadwal_to_dict(_load_jadwal(db, j.id_jadwal)) + + +# ─── PERTEMUAN ──────────────────────────────────────────────────────────────── + + +def get_all_pertemuan( + db, id_jadwal=None, status_filter=None, skip=0, limit=100, jadwal_ids=None +): + import logging + log = logging.getLogger(__name__) + + from app.models.matakuliah import MataKuliah + from app.models.presensi import Presensi + + q = db.query(Pertemuan) + if jadwal_ids is not None: + q = q.filter(Pertemuan.id_jadwal.in_(jadwal_ids)) + log.info(f"[get_all_pertemuan] Filtering by jadwal_ids={jadwal_ids}") + elif id_jadwal: + q = q.filter(Pertemuan.id_jadwal == id_jadwal) + if status_filter: + q = q.filter(Pertemuan.status_pertemuan == status_filter) + pertemuan_list = q.order_by(Pertemuan.pertemuan_ke).offset(skip).limit(limit).all() + + log.info(f"[get_all_pertemuan] Found {len(pertemuan_list)} pertemuan") + + result = [] + for p in pertemuan_list: + # ponytail: N+1 OK — active sessions are typically 1-5; upgrade path: joinedload + jadwal = db.query(Jadwal).filter(Jadwal.id_jadwal == p.id_jadwal).first() + penawaran = ( + db.query(PenawaranMatakuliah) + .filter(PenawaranMatakuliah.id_penawaran == jadwal.id_penawaran) + .first() + if jadwal + else None + ) + mk = ( + db.query(MataKuliah) + .filter(MataKuliah.id_matakuliah == penawaran.id_matakuliah) + .first() + if penawaran + else None + ) + presensi_list = ( + db.query(Presensi).filter(Presensi.id_pertemuan == p.id_pertemuan).all() + ) + total = len(presensi_list) + sudah_hadir = sum( + 1 + for pr in presensi_list + if pr.status_presensi.value in ("hadir", "terlambat") + ) + belum = sum(1 for pr in presensi_list if pr.status_presensi.value == "belum") + total_alpa = sum( + 1 + for pr in presensi_list + if pr.status_presensi.value in ("alpha", "izin", "sakit") + ) + + # Build nama_kelas from PenawaranKelas + nama_kelas = None + if penawaran: + from app.models.penawaran_matakuliah import PenawaranKelas + + pk_list = ( + db.query(PenawaranKelas) + .filter(PenawaranKelas.id_penawaran == penawaran.id_penawaran) + .all() + ) + if pk_list: + pk = pk_list[0] + parts = [] + if pk.prodi: + parts.append(pk.prodi.kode_prodi) + if pk.angkatan: + parts.append(str(pk.angkatan.tahun_angkatan)) + if pk.golongan: + parts.append(pk.golongan.nama_golongan) + nama_kelas = "-".join(parts) if parts else None + + result.append( + { + "id_pertemuan": p.id_pertemuan, + "id_jadwal": p.id_jadwal, + "pertemuan_ke": p.pertemuan_ke, + "tanggal": p.tanggal, + "batas_presensi": p.batas_presensi, + "status_pertemuan": p.status_pertemuan.value, + "id_geolokasi": p.id_geolokasi, + "mode_lokasi": p.mode_lokasi.value if p.mode_lokasi else "bebas", + "nama_matakuliah": mk.nama_matakuliah if mk else None, + "kode_matakuliah": mk.kode_matakuliah if mk else None, + "nama_kelas": nama_kelas, + "total_mahasiswa": total, + "sudah_hadir": sudah_hadir, + "belum_presensi": belum, + "total_hadir": sudah_hadir, + "total_alpa": total_alpa, + } + ) + return result + + +def get_pertemuan_by_id(db, pertemuan_id: int): + p = db.query(Pertemuan).filter(Pertemuan.id_pertemuan == pertemuan_id).first() + if not p: + raise HTTPException(404, "Pertemuan tidak ditemukan") + return p + + +def create_pertemuan(db, payload: PertemuanCreate): + jadwal = db.query(Jadwal).filter(Jadwal.id_jadwal == payload.id_jadwal).first() + if not jadwal: + raise HTTPException(404, "Jadwal tidak ditemukan") + if ( + db.query(Pertemuan) + .filter( + Pertemuan.id_jadwal == payload.id_jadwal, + Pertemuan.pertemuan_ke == payload.pertemuan_ke, + ) + .first() + ): + raise HTTPException(400, f"Pertemuan ke-{payload.pertemuan_ke} sudah ada") + p = Pertemuan(**payload.model_dump()) + db.add(p) + db.flush() + _create_presensi_belum(db, p.id_pertemuan, jadwal.id_penawaran) + db.commit() + db.refresh(p) + return p + + +def update_pertemuan(db, pertemuan_id: int, payload: PertemuanUpdate): + p = get_pertemuan_by_id(db, pertemuan_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(p, k, v) + db.commit() + db.refresh(p) + return p + + +def mulai_pertemuan(db, pertemuan_id: int, durasi_menit: int = 15): + p = get_pertemuan_by_id(db, pertemuan_id) + if p.status_pertemuan == StatusPertemuanEnum.sedang: + raise HTTPException(400, "Pertemuan sudah sedang berlangsung") + p.status_pertemuan = StatusPertemuanEnum.sedang + import zoneinfo + wib = zoneinfo.ZoneInfo("Asia/Jakarta") + p.batas_presensi = datetime.now(wib).replace(tzinfo=None) + timedelta(minutes=durasi_menit) + db.commit() + db.refresh(p) + return p + + +def selesai_pertemuan(db, pertemuan_id: int): + from app.models.presensi import Presensi + from app.models.presensi import StatusPresensiEnum as SP + from app.models.history_presensi import HistoryPresensi + + p = get_pertemuan_by_id(db, pertemuan_id) + + # Langkah 1: Ubah status 'belum' → 'alpha' TERLEBIH DAHULU + # agar saat di-copy ke history_presensi tidak ada nilai 'belum' + # yang tidak valid di tabel history_presensi. + db.query(Presensi).filter( + Presensi.id_pertemuan == pertemuan_id, + Presensi.status_presensi == SP.belum, + ).update({"status_presensi": SP.alpha}) + db.flush() # Terapkan update ke session sebelum query ulang + + # Langkah 2: Ambil semua presensi (sekarang sudah tidak ada status 'belum') + presensi_list = db.query(Presensi).filter( + Presensi.id_pertemuan == pertemuan_id + ).all() + + # Langkah 3: Copy semua presensi ke history_presensi + for pres in presensi_list: + history = HistoryPresensi( + id_mahasiswa=pres.id_mahasiswa, + id_pertemuan=pres.id_pertemuan, + waktu_presensi=pres.waktu_presensi, + status_presensi=pres.status_presensi, + foto_verifikasi=pres.foto_verifikasi, + confidence_score=pres.confidence_score, + lbph_score=pres.lbph_score, + cnn_score=pres.cnn_score, + metode_deteksi=pres.metode_deteksi, + latitude_presensi=pres.latitude_presensi, + longitude_presensi=pres.longitude_presensi, + jarak_meter=pres.jarak_meter, + ) + db.add(history) + + # Langkah 4: Update status pertemuan menjadi selesai + p.status_pertemuan = StatusPertemuanEnum.selesai + + db.commit() + db.refresh(p) + return p + + + +def archive_pertemuan_by_date(db, tanggal_batas: datetime = None): + """ + Archive semua presensi yang tanggalnya sudah lewat ATAU batas_presensi sudah lewat. + Fungsi ini digunakan untuk auto-archive presensi yang tidak ditandai selesai secara manual. + """ + import logging + log = logging.getLogger(__name__) + + from sqlalchemy import or_, and_ + from app.models.presensi import Presensi, StatusPresensiEnum as SP + from app.models.history_presensi import HistoryPresensi, StatusHistoryPresensiEnum as HP + + if tanggal_batas is None: + import zoneinfo + wib = zoneinfo.ZoneInfo("Asia/Jakarta") + tanggal_batas = datetime.now(wib).replace(tzinfo=None) + + log.info(f"[ARCHIVE] Starting archive with tanggal_batas={tanggal_batas}") + log.info(f"[ARCHIVE] Archive condition: tanggal < {tanggal_batas.date()} OR batas_presensi < {tanggal_batas}") + + # Ambil semua pertemuan yang belum selesai dan: + # 1. tanggalnya sudah lewat, ATAU + # 2. batas_presensi (tenggat waktu) sudah lewat + old_pertemuan_list = db.query(Pertemuan).filter( + Pertemuan.status_pertemuan.in_([StatusPertemuanEnum.belum, StatusPertemuanEnum.sedang]), + or_( + Pertemuan.tanggal < tanggal_batas.date(), + and_( + Pertemuan.batas_presensi != None, + Pertemuan.batas_presensi < tanggal_batas, + ), + ), + ).all() + + log.info(f"[ARCHIVE] Found {len(old_pertemuan_list)} old pertemuan to archive") + for p in old_pertemuan_list: + log.info(f"[ARCHIVE] OLD MEETING CANDIDATE: id={p.id_pertemuan}, tanggal={p.tanggal}, status={p.status_pertemuan}") + + archived_count = 0 + archived_pertemuan_ids = [] + for p in old_pertemuan_list: + log.info(f"[ARCHIVE] Processing pertemuan id={p.id_pertemuan}, tanggal={p.tanggal}, status={p.status_pertemuan}") + + # Cek apakah sudah ada di history_presensi + existing_in_history = db.query(HistoryPresensi).filter( + HistoryPresensi.id_pertemuan == p.id_pertemuan + ).first() + + if existing_in_history: + log.info(f"[ARCHIVE] Pertemuan {p.id_pertemuan} already in history, skipping") + continue + + # Ambil semua presensi untuk pertemuan ini + presensi_list = db.query(Presensi).filter( + Presensi.id_pertemuan == p.id_pertemuan + ).all() + + log.info(f"[ARCHIVE] Found {len(presensi_list)} presensi records for pertemuan {p.id_pertemuan}") + + # Copy semua presensi ke history_presensi + for pres in presensi_list: + try: + # Konversi status_presensi: "belum" -> "alpha", enum lain tetap + status_val = pres.status_presensi + if status_val == SP.belum: + history_status = HP.alpha + elif status_val == SP.hadir: + history_status = HP.hadir + elif status_val == SP.terlambat: + history_status = HP.terlambat + elif status_val == SP.izin: + history_status = HP.izin + elif status_val == SP.sakit: + history_status = HP.sakit + elif status_val == SP.alpha: + history_status = HP.alpha + else: + history_status = HP.alpha # default fallback + + history = HistoryPresensi( + id_mahasiswa=pres.id_mahasiswa, + id_pertemuan=pres.id_pertemuan, + waktu_presensi=pres.waktu_presensi, + status_presensi=history_status, + foto_verifikasi=pres.foto_verifikasi, + confidence_score=pres.confidence_score, + lbph_score=pres.lbph_score, + cnn_score=pres.cnn_score, + metode_deteksi=pres.metode_deteksi, + latitude_presensi=pres.latitude_presensi, + longitude_presensi=pres.longitude_presensi, + jarak_meter=pres.jarak_meter, + ) + db.add(history) + log.info(f"[ARCHIVE] Added history for mhs={pres.id_mahasiswa}, status={history_status}") + except Exception as e: + log.error(f"[ARCHIVE] Error adding history: {e}") + import traceback + traceback.print_exc() + + # Update status pertemuan menjadi selesai + p.status_pertemuan = StatusPertemuanEnum.selesai + + # Students who never scanned become alpha (absent) + db.query(Presensi).filter( + Presensi.id_pertemuan == p.id_pertemuan, + Presensi.status_presensi == SP.belum, + ).update({"status_presensi": SP.alpha}) + + archived_count += 1 + archived_pertemuan_ids.append(p.id_pertemuan) + + log.info(f"[ARCHIVE] Committing {archived_count} archived records") + db.commit() + log.info(f"[ARCHIVE] Done! archived_count={archived_count}") + return {"archived_count": archived_count, "pertemuan_ids": archived_pertemuan_ids} + + +def batalkan_pertemuan(db, pertemuan_id: int): + p = get_pertemuan_by_id(db, pertemuan_id) + p.status_pertemuan = StatusPertemuanEnum.dibatalkan + db.commit() + db.refresh(p) + return p + + +def delete_pertemuan(db, pertemuan_id: int): + from app.models.presensi import Presensi + from app.models.history_presensi import HistoryPresensi + + p = get_pertemuan_by_id(db, pertemuan_id) + # Hapus semua history presensi terkait terlebih dahulu + db.query(HistoryPresensi).filter(HistoryPresensi.id_pertemuan == pertemuan_id).delete( + synchronize_session="fetch" + ) + # Hapus semua presensi terkait + db.query(Presensi).filter(Presensi.id_pertemuan == pertemuan_id).delete( + synchronize_session="fetch" + ) + db.delete(p) + db.commit() + + +def create_pertemuan_bebas(db: Session, payload, current_user=None): + """ + Buat sesi presensi bebas: pilih matakuliah + kelas secara langsung. + Otomatis cari/buat penawaran → jadwal → pertemuan, + lalu buat record presensi alpha untuk semua mahasiswa di kelas tersebut. + Jika current_user adalah dosen, otomatis tambahkan sebagai Pengajar agar + sesi baru muncul di /kelas-mata-kuliah/me. + """ + from datetime import time as dt_time + + from app.models.angkatan import Angkatan + from app.models.golongan import Golongan + from app.models.kelas import Kelas + from app.models.mahasiswa import Mahasiswa + from app.models.matakuliah import MataKuliah + from app.models.penawaran_matakuliah import PenawaranKelas, PenawaranMatakuliah + from app.models.presensi import Presensi, StatusPresensiEnum + + # --- Validasi input --- + kelas = db.query(Kelas).filter(Kelas.id == payload.id_kelas).first() + if not kelas: + raise HTTPException(404, "Kelas tidak ditemukan") + + mk = ( + db.query(MataKuliah) + .filter(MataKuliah.id_matakuliah == payload.id_matakuliah) + .first() + ) + if not mk: + raise HTTPException(404, "Mata kuliah tidak ditemukan") + + # --- Cari mahasiswa berdasarkan kelas --- + # Cari id_angkatan dan id_golongan terlebih dahulu + ang_obj = None + gol_obj = None + if kelas.angkatan: + ang_obj = ( + db.query(Angkatan).filter(Angkatan.tahun_angkatan == kelas.angkatan).first() + ) + if kelas.golongan: + gol_obj = ( + db.query(Golongan).filter(Golongan.nama_golongan == kelas.golongan).first() + ) + + q = db.query(Mahasiswa).filter(Mahasiswa.id_prodi == kelas.id_prodi) + if ang_obj: + q = q.filter(Mahasiswa.id_angkatan == ang_obj.id_angkatan) + if gol_obj: + q = q.filter(Mahasiswa.id_golongan == gol_obj.id_golongan) + mahasiswa_list = q.all() + + # --- Cari atau buat PenawaranMatakuliah --- + penawaran = ( + db.query(PenawaranMatakuliah) + .filter( + PenawaranMatakuliah.id_matakuliah == payload.id_matakuliah, + PenawaranMatakuliah.semester == payload.semester, + PenawaranMatakuliah.tahun_ajaran == payload.tahun_ajaran, + ) + .first() + ) + if not penawaran: + penawaran = PenawaranMatakuliah( + id_matakuliah=payload.id_matakuliah, + semester=payload.semester, + tahun_ajaran=payload.tahun_ajaran, + status="aktif", + ) + db.add(penawaran) + db.flush() + + # --- Cari atau buat PenawaranKelas --- + # Gunakan ang_obj / gol_obj yang sudah dicari di atas + ang_id = ang_obj.id_angkatan if ang_obj else None + gol_id = gol_obj.id_golongan if gol_obj else None + + if ang_id and gol_id: + pkel = ( + db.query(PenawaranKelas) + .filter( + PenawaranKelas.id_penawaran == penawaran.id_penawaran, + PenawaranKelas.id_prodi == kelas.id_prodi, + PenawaranKelas.id_angkatan == ang_id, + PenawaranKelas.id_golongan == gol_id, + ) + .first() + ) + if not pkel: + pkel = PenawaranKelas( + id_penawaran=penawaran.id_penawaran, + id_prodi=kelas.id_prodi, + id_angkatan=ang_id, + id_golongan=gol_id, + ) + db.add(pkel) + db.flush() + + # --- Cari atau buat Jadwal --- + jam_mulai = payload.jam_mulai or dt_time(8, 0) + jam_selesai = payload.jam_selesai or dt_time(10, 0) + + jadwal = ( + db.query(Jadwal) + .filter( + Jadwal.id_penawaran == penawaran.id_penawaran, + ) + .first() + ) + if not jadwal: + jadwal = Jadwal( + id_penawaran=penawaran.id_penawaran, + hari=HariEnum.senin, + jam_mulai=jam_mulai, + jam_selesai=jam_selesai, + status=StatusJadwalEnum.aktif, + ) + db.add(jadwal) + db.flush() + + # --- Auto-tambah dosen sebagai Pengajar agar session muncul di /kelas-mata-kuliah/me --- + if current_user is not None: + from app.models.dosen import Dosen + from app.models.user import UserRoleEnum + if current_user.role == UserRoleEnum.dosen: + dosen = db.query(Dosen).filter(Dosen.email == current_user.email).first() + if dosen: + # Cek apakah pengajar sudah ada untuk penawaran ini + existing_pengajar = ( + db.query(Pengajar) + .filter( + Pengajar.id_penawaran == penawaran.id_penawaran, + Pengajar.id_dosen == dosen.id_dosen, + ) + .first() + ) + if not existing_pengajar: + from app.models.penawaran_matakuliah import RolePengajarEnum + new_pengajar = Pengajar( + id_penawaran=penawaran.id_penawaran, + id_dosen=dosen.id_dosen, + role=RolePengajarEnum.koordinator, + ) + db.add(new_pengajar) + db.flush() + else: + # Update jam jika diberikan + if payload.jam_mulai: + jadwal.jam_mulai = payload.jam_mulai + if payload.jam_selesai: + jadwal.jam_selesai = payload.jam_selesai + + # --- Juga pastikan dosen terdaftar sebagai Pengajar untuk penawaran yang sudah ada --- + if current_user is not None: + from app.models.dosen import Dosen + from app.models.user import UserRoleEnum + if current_user.role == UserRoleEnum.dosen: + dosen = db.query(Dosen).filter(Dosen.email == current_user.email).first() + if dosen: + existing_pengajar = ( + db.query(Pengajar) + .filter( + Pengajar.id_penawaran == penawaran.id_penawaran, + Pengajar.id_dosen == dosen.id_dosen, + ) + .first() + ) + if not existing_pengajar: + from app.models.penawaran_matakuliah import RolePengajarEnum + new_pengajar = Pengajar( + id_penawaran=penawaran.id_penawaran, + id_dosen=dosen.id_dosen, + role=RolePengajarEnum.koordinator, + ) + db.add(new_pengajar) + db.flush() + + # --- Cek duplikasi pertemuan --- + # Hanya tolak duplikat jika pertemuan yang ada masih aktif (belum/sedang). + # Pertemuan yang sudah selesai/dibatalkan diabaikan agar dosen bisa membuat + # ulang pertemuan ke-N yang sebelumnya sudah selesai. + import logging + log = logging.getLogger(__name__) + + existing_pertemuan = ( + db.query(Pertemuan) + .filter( + Pertemuan.id_jadwal == jadwal.id_jadwal, + Pertemuan.pertemuan_ke == payload.pertemuan_ke, + ) + .first() + ) + if existing_pertemuan: + log.warning( + f"[DEBUG] Duplicate check found existing pertemuan: " + f"id_pertemuan={existing_pertemuan.id_pertemuan}, " + f"id_jadwal={existing_pertemuan.id_jadwal}, " + f"pertemuan_ke={existing_pertemuan.pertemuan_ke}, " + f"status={existing_pertemuan.status_pertemuan}, " + f"tanggal={existing_pertemuan.tanggal}" + ) + raise HTTPException( + 400, + f"Pertemuan ke-{payload.pertemuan_ke} untuk kelas dan mata kuliah ini sudah pernah dibuat " + f"(Status: {existing_pertemuan.status_pertemuan.value}). " + f"Anda tidak dapat membuat pertemuan dengan urutan yang sama berulang kali.", + ) + + # --- Hitung batas presensi berdasarkan jam selesai (asumsi WIB / UTC+7) --- + try: + from datetime import datetime, timezone, timedelta + # Jadikan kombinasi tanggal + jam_selesai sebagai local time WIB + batas_local = datetime.combine(payload.tanggal, jam_selesai) + batas_utc = batas_local + except Exception: + import zoneinfo + wib = zoneinfo.ZoneInfo("Asia/Jakarta") + batas_utc = datetime.now(wib).replace(tzinfo=None) + timedelta(hours=12) + + # --- Buat Pertemuan (langsung aktif/sedang agar mahasiswa bisa langsung absen) --- + pertemuan = Pertemuan( + id_jadwal=jadwal.id_jadwal, + pertemuan_ke=payload.pertemuan_ke, + tanggal=payload.tanggal, + status_pertemuan=StatusPertemuanEnum.sedang, + batas_presensi=batas_utc, + id_geolokasi=getattr(payload, "id_geolokasi", None), + mode_lokasi=getattr(payload, "mode_lokasi", "bebas"), + ) + db.add(pertemuan) + db.flush() + + # --- Buat Presensi belum untuk semua mahasiswa --- + for mhs in mahasiswa_list: + db.add( + Presensi( + id_pertemuan=pertemuan.id_pertemuan, + id_mahasiswa=mhs.id_mahasiswa, + status_presensi=StatusPresensiEnum.belum, + ) + ) + + db.commit() + db.refresh(pertemuan) + + return { + "id_pertemuan": pertemuan.id_pertemuan, + "id_jadwal": pertemuan.id_jadwal, + "pertemuan_ke": pertemuan.pertemuan_ke, + "tanggal": str(pertemuan.tanggal), + "status_pertemuan": pertemuan.status_pertemuan.value, + "nama_mk": mk.nama_matakuliah, + "kode_mk": mk.kode_matakuliah, + "nama_kelas": kelas.nama_kelas, + "total_mahasiswa": len(mahasiswa_list), + } diff --git a/facerecognition_presensi/backend/app/controllers/kelas_controller.py b/facerecognition_presensi/backend/app/controllers/kelas_controller.py new file mode 100644 index 0000000..a990906 --- /dev/null +++ b/facerecognition_presensi/backend/app/controllers/kelas_controller.py @@ -0,0 +1,82 @@ +from app.models.kelas import Kelas +from app.models.prodi import Prodi +from app.schemas.kelas import KelasCreate, KelasUpdate +from fastapi import HTTPException +from sqlalchemy.orm import Session + + +def get_all_kelas( + db: Session, id_prodi: int | None = None, angkatan: int | None = None +): + q = db.query(Kelas) + if id_prodi: + q = q.filter(Kelas.id_prodi == id_prodi) + if angkatan: + q = q.filter(Kelas.angkatan == angkatan) + return q.order_by(Kelas.nama_kelas).all() + + +def get_kelas_by_id(db: Session, kelas_id: int) -> Kelas: + k = db.query(Kelas).filter(Kelas.id == kelas_id).first() + if not k: + raise HTTPException(status_code=404, detail="Kelas tidak ditemukan") + return k + + +def create_kelas(db: Session, payload: KelasCreate) -> Kelas: + # Validate prodi exists + if not db.query(Prodi).filter(Prodi.id_prodi == payload.id_prodi).first(): + raise HTTPException(status_code=400, detail="Program Studi tidak ditemukan") + # Prevent duplicate nama_kelas within the same prodi + angkatan + dup = ( + db.query(Kelas) + .filter( + Kelas.id_prodi == payload.id_prodi, + Kelas.nama_kelas == payload.nama_kelas, + ) + .first() + ) + if dup: + raise HTTPException( + status_code=400, detail="Nama kelas sudah ada untuk prodi ini" + ) + k = Kelas(**payload.model_dump()) + db.add(k) + db.commit() + db.refresh(k) + return k + + +def update_kelas(db: Session, kelas_id: int, payload: KelasUpdate) -> Kelas: + k = get_kelas_by_id(db, kelas_id) + data = payload.model_dump(exclude_none=True) + if "id_prodi" in data: + if not db.query(Prodi).filter(Prodi.id_prodi == data["id_prodi"]).first(): + raise HTTPException(status_code=400, detail="Program Studi tidak ditemukan") + # Duplicate check (exclude self) + new_name = data.get("nama_kelas", k.nama_kelas) + new_prodi = data.get("id_prodi", k.id_prodi) + dup = ( + db.query(Kelas) + .filter( + Kelas.id_prodi == new_prodi, + Kelas.nama_kelas == new_name, + Kelas.id != kelas_id, + ) + .first() + ) + if dup: + raise HTTPException( + status_code=400, detail="Nama kelas sudah ada untuk prodi ini" + ) + for field, val in data.items(): + setattr(k, field, val) + db.commit() + db.refresh(k) + return k + + +def delete_kelas(db: Session, kelas_id: int): + k = get_kelas_by_id(db, kelas_id) + db.delete(k) + db.commit() diff --git a/facerecognition_presensi/backend/app/controllers/mahasiswa_controller.py b/facerecognition_presensi/backend/app/controllers/mahasiswa_controller.py new file mode 100644 index 0000000..8c2fb44 --- /dev/null +++ b/facerecognition_presensi/backend/app/controllers/mahasiswa_controller.py @@ -0,0 +1,168 @@ +from fastapi import HTTPException +from sqlalchemy.orm import Session, joinedload +from app.models.mahasiswa import Mahasiswa +from app.models.prodi import Prodi +from app.models.angkatan import Angkatan +from app.models.golongan import Golongan +from app.models.kelas import Kelas +from app.models.user import User, UserRoleEnum +from app.schemas.dosen import MahasiswaCreate, MahasiswaUpdate +from app.utils.security import get_password_hash + + +def get_all_mahasiswa(db: Session, id_prodi=None, id_angkatan=None, id_golongan=None, status_filter=None, skip=0, limit=100): + q = db.query(Mahasiswa) + if id_prodi: q = q.filter(Mahasiswa.id_prodi == id_prodi) + if id_angkatan: q = q.filter(Mahasiswa.id_angkatan == id_angkatan) + if id_golongan: q = q.filter(Mahasiswa.id_golongan == id_golongan) + if status_filter: q = q.filter(Mahasiswa.status == status_filter) + return q.offset(skip).limit(limit).all() + + +def get_mahasiswa_by_id(db: Session, mahasiswa_id: int): + m = db.query(Mahasiswa).filter(Mahasiswa.id_mahasiswa == mahasiswa_id).first() + if not m: + raise HTTPException(status_code=404, detail="Mahasiswa tidak ditemukan") + return m + + +def get_mahasiswa_by_nim(db: Session, nim: str): + m = db.query(Mahasiswa).filter(Mahasiswa.nim == nim).first() + if not m: + raise HTTPException(status_code=404, detail="Mahasiswa tidak ditemukan") + return m + + +def create_mahasiswa(db: Session, payload: MahasiswaCreate): + # Cek NIM duplikat + if db.query(Mahasiswa).filter(Mahasiswa.nim == payload.nim).first(): + raise HTTPException(status_code=400, detail="NIM sudah terdaftar") + + # Cek email duplikat (jika diisi manual) + if payload.email and db.query(Mahasiswa).filter(Mahasiswa.email == payload.email).first(): + raise HTTPException(status_code=400, detail="Email mahasiswa sudah terdaftar") + + # Validasi foreign key + if not db.query(Prodi).filter(Prodi.id_prodi == payload.id_prodi).first(): + raise HTTPException(status_code=404, detail="Prodi tidak ditemukan") + if not db.query(Angkatan).filter(Angkatan.id_angkatan == payload.id_angkatan).first(): + raise HTTPException(status_code=404, detail="Angkatan tidak ditemukan") + if not db.query(Golongan).filter(Golongan.id_golongan == payload.id_golongan).first(): + raise HTTPException(status_code=404, detail="Golongan tidak ditemukan") + if payload.id_kelas and not db.query(Kelas).filter(Kelas.id == payload.id_kelas).first(): + raise HTTPException(status_code=404, detail="Kelas tidak ditemukan") + + # Format email otomatis dari NIM jika tidak ada email manual + student_email = payload.email if payload.email else f"{payload.nim.lower()}@student.polije.ac.id" + + # Cek apakah user dengan email tsb sudah ada + if db.query(User).filter(User.email == student_email).first(): + raise HTTPException(status_code=400, detail=f"User dengan email {student_email} sudah terdaftar") + + try: + # Buat entri mahasiswa + mahasiswa_data = payload.model_dump() + mahasiswa_data["email"] = student_email + m = Mahasiswa(**mahasiswa_data) + db.add(m) + + # Buat entri user secara otomatis + # name = NIM, email = nim@student.polije.ac.id, password = NIM, role = mahasiswa + new_user = User( + name=payload.nim, + email=student_email, + password=get_password_hash(payload.nim), + role=UserRoleEnum.mahasiswa, + ) + db.add(new_user) + + db.commit() + db.refresh(m) + return m + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"Gagal menyimpan data mahasiswa: {str(e)}") + + +def update_mahasiswa(db: Session, mahasiswa_id: int, payload: MahasiswaUpdate): + m = get_mahasiswa_by_id(db, mahasiswa_id) + data = payload.model_dump(exclude_none=True) + if "id_prodi" in data and not db.query(Prodi).filter(Prodi.id_prodi == data["id_prodi"]).first(): + raise HTTPException(status_code=404, detail="Prodi tidak ditemukan") + if "id_angkatan" in data and not db.query(Angkatan).filter(Angkatan.id_angkatan == data["id_angkatan"]).first(): + raise HTTPException(status_code=404, detail="Angkatan tidak ditemukan") + if "id_golongan" in data and not db.query(Golongan).filter(Golongan.id_golongan == data["id_golongan"]).first(): + raise HTTPException(status_code=404, detail="Golongan tidak ditemukan") + if "id_kelas" in data and data["id_kelas"] and not db.query(Kelas).filter(Kelas.id == data["id_kelas"]).first(): + raise HTTPException(status_code=404, detail="Kelas tidak ditemukan") + for k, v in data.items(): + setattr(m, k, v) + db.commit(); db.refresh(m) + return m + + +def delete_mahasiswa(db: Session, mahasiswa_id: int): + m = get_mahasiswa_by_id(db, mahasiswa_id) + db.delete(m); db.commit() + +def bulk_create_mahasiswa(db: Session, payloads: list[MahasiswaCreate]): + success_count = 0 + errors = [] + + for i, payload in enumerate(payloads): + try: + if db.query(Mahasiswa).filter(Mahasiswa.nim == payload.nim).first(): + errors.append(f"Baris {i+1}: NIM {payload.nim} sudah terdaftar") + continue + + student_email = payload.email if payload.email else f"{payload.nim.lower()}@student.polije.ac.id" + if db.query(User).filter(User.email == student_email).first(): + errors.append(f"Baris {i+1}: Email {student_email} sudah terdaftar") + continue + + # Validasi foreign key minimal untuk keamanan + if not db.query(Prodi).filter(Prodi.id_prodi == payload.id_prodi).first(): + errors.append(f"Baris {i+1}: Prodi tidak ditemukan") + continue + if not db.query(Angkatan).filter(Angkatan.id_angkatan == payload.id_angkatan).first(): + errors.append(f"Baris {i+1}: Angkatan tidak ditemukan") + continue + if not db.query(Golongan).filter(Golongan.id_golongan == payload.id_golongan).first(): + errors.append(f"Baris {i+1}: Golongan tidak ditemukan") + continue + if payload.id_kelas and not db.query(Kelas).filter(Kelas.id == payload.id_kelas).first(): + errors.append(f"Baris {i+1}: Kelas tidak ditemukan") + continue + + mahasiswa_data = payload.model_dump() + mahasiswa_data["email"] = student_email + m = Mahasiswa(**mahasiswa_data) + db.add(m) + + new_user = User( + name=payload.nim, + email=student_email, + password=get_password_hash(payload.nim), + role=UserRoleEnum.mahasiswa, + ) + db.add(new_user) + db.commit() + success_count += 1 + except Exception as e: + db.rollback() + errors.append(f"Baris {i+1}: {str(e)}") + + return {"success": success_count, "failed": len(errors), "errors": errors} + +def bulk_delete_mahasiswa(db: Session, mahasiswa_ids: list[int]): + try: + mahasiswa_list = db.query(Mahasiswa).filter(Mahasiswa.id_mahasiswa.in_(mahasiswa_ids)).all() + for m in mahasiswa_list: + db.delete(m) + db.commit() + return {"success": len(mahasiswa_list)} + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"Gagal menghapus bulk: {str(e)}") diff --git a/facerecognition_presensi/backend/app/controllers/master_controller.py b/facerecognition_presensi/backend/app/controllers/master_controller.py new file mode 100644 index 0000000..a4f6965 --- /dev/null +++ b/facerecognition_presensi/backend/app/controllers/master_controller.py @@ -0,0 +1,189 @@ +""" +Controllers untuk semua entitas master data: +Prodi, Angkatan, Golongan, MataKuliah, Dosen +""" +from fastapi import HTTPException +from sqlalchemy.orm import Session +from app.models.prodi import Prodi +from app.models.angkatan import Angkatan +from app.models.golongan import Golongan +from app.models.matakuliah import MataKuliah +from app.models.dosen import Dosen +from app.schemas.master import ( + ProdiCreate, ProdiUpdate, + AngkatanCreate, AngkatanUpdate, + GolonganCreate, GolonganUpdate, + MataKuliahCreate, MataKuliahUpdate, +) +from app.schemas.dosen import DosenCreate, DosenUpdate + + +# ─── PRODI ──────────────────────────────────────────────────────────────────── + +def get_all_prodi(db: Session): + return db.query(Prodi).all() + +def get_prodi_by_id(db: Session, prodi_id: int): + p = db.query(Prodi).filter(Prodi.id_prodi == prodi_id).first() + if not p: + raise HTTPException(status_code=404, detail="Prodi tidak ditemukan") + return p + +def create_prodi(db: Session, payload: ProdiCreate): + if db.query(Prodi).filter(Prodi.kode_prodi == payload.kode_prodi).first(): + raise HTTPException(status_code=400, detail="Kode prodi sudah ada") + p = Prodi(**payload.model_dump()) + db.add(p); db.commit(); db.refresh(p) + return p + +def update_prodi(db: Session, prodi_id: int, payload: ProdiUpdate): + p = get_prodi_by_id(db, prodi_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(p, k, v) + db.commit(); db.refresh(p) + return p + +def delete_prodi(db: Session, prodi_id: int): + p = get_prodi_by_id(db, prodi_id) + db.delete(p); db.commit() + + +# ─── ANGKATAN ───────────────────────────────────────────────────────────────── + +def get_all_angkatan(db: Session): + return db.query(Angkatan).order_by(Angkatan.tahun_angkatan).all() + +def get_angkatan_by_id(db: Session, angkatan_id: int): + a = db.query(Angkatan).filter(Angkatan.id_angkatan == angkatan_id).first() + if not a: + raise HTTPException(status_code=404, detail="Angkatan tidak ditemukan") + return a + +def create_angkatan(db: Session, payload: AngkatanCreate): + if db.query(Angkatan).filter(Angkatan.tahun_angkatan == payload.tahun_angkatan).first(): + raise HTTPException(status_code=400, detail="Tahun angkatan sudah ada") + a = Angkatan(**payload.model_dump()) + db.add(a); db.commit(); db.refresh(a) + return a + +def update_angkatan(db: Session, angkatan_id: int, payload: AngkatanUpdate): + a = get_angkatan_by_id(db, angkatan_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(a, k, v) + db.commit(); db.refresh(a) + return a + +def delete_angkatan(db: Session, angkatan_id: int): + a = get_angkatan_by_id(db, angkatan_id) + db.delete(a); db.commit() + + +# ─── GOLONGAN ───────────────────────────────────────────────────────────────── + +def get_all_golongan(db: Session): + return db.query(Golongan).order_by(Golongan.nama_golongan).all() + +def get_golongan_by_id(db: Session, golongan_id: int): + g = db.query(Golongan).filter(Golongan.id_golongan == golongan_id).first() + if not g: + raise HTTPException(status_code=404, detail="Golongan tidak ditemukan") + return g + +def create_golongan(db: Session, payload: GolonganCreate): + g = Golongan(**payload.model_dump()) + db.add(g); db.commit(); db.refresh(g) + return g + +def update_golongan(db: Session, golongan_id: int, payload: GolonganUpdate): + g = get_golongan_by_id(db, golongan_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(g, k, v) + db.commit(); db.refresh(g) + return g + +def delete_golongan(db: Session, golongan_id: int): + g = get_golongan_by_id(db, golongan_id) + db.delete(g); db.commit() + + +# ─── MATA KULIAH ────────────────────────────────────────────────────────────── + +def get_all_matakuliah(db: Session): + return db.query(MataKuliah).all() + +def get_matakuliah_by_id(db: Session, mk_id: int): + mk = db.query(MataKuliah).filter(MataKuliah.id_matakuliah == mk_id).first() + if not mk: + raise HTTPException(status_code=404, detail="Mata kuliah tidak ditemukan") + return mk + +def create_matakuliah(db: Session, payload: MataKuliahCreate): + if db.query(MataKuliah).filter(MataKuliah.kode_matakuliah == payload.kode_matakuliah).first(): + raise HTTPException(status_code=400, detail="Kode mata kuliah sudah ada") + mk = MataKuliah(**payload.model_dump()) + db.add(mk); db.commit(); db.refresh(mk) + return mk + +def update_matakuliah(db: Session, mk_id: int, payload: MataKuliahUpdate): + mk = get_matakuliah_by_id(db, mk_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(mk, k, v) + db.commit(); db.refresh(mk) + return mk + +def delete_matakuliah(db: Session, mk_id: int): + mk = get_matakuliah_by_id(db, mk_id) + try: + db.delete(mk) + db.commit() + except Exception as e: + db.rollback() + # Check for FK constraint violations + err = str(e).lower() + if "foreign key" in err or "constraint" in err or "restrict" in err: + raise HTTPException( + status_code=409, + detail="Mata kuliah tidak dapat dihapus karena masih memiliki data terkait (penawaran/jadwal). Hapus data terkait terlebih dahulu atau hubungi admin." + ) + raise HTTPException(status_code=500, detail=f"Gagal menghapus mata kuliah: {str(e)}") + + +# ─── DOSEN ──────────────────────────────────────────────────────────────────── + +def get_all_dosen(db: Session, status_filter: str | None = None): + q = db.query(Dosen) + if status_filter: + q = q.filter(Dosen.status == status_filter) + return q.all() + +def get_dosen_by_id(db: Session, dosen_id: int): + d = db.query(Dosen).filter(Dosen.id_dosen == dosen_id).first() + if not d: + raise HTTPException(status_code=404, detail="Dosen tidak ditemukan") + return d + +def get_dosen_by_nidn(db: Session, nidn: str): + d = db.query(Dosen).filter(Dosen.nidn == nidn).first() + if not d: + raise HTTPException(status_code=404, detail="Dosen tidak ditemukan") + return d + +def create_dosen(db: Session, payload: DosenCreate): + if db.query(Dosen).filter(Dosen.nidn == payload.nidn).first(): + raise HTTPException(status_code=400, detail="NIDN sudah terdaftar") + if payload.email and db.query(Dosen).filter(Dosen.email == payload.email).first(): + raise HTTPException(status_code=400, detail="Email dosen sudah terdaftar") + d = Dosen(**payload.model_dump()) + db.add(d); db.commit(); db.refresh(d) + return d + +def update_dosen(db: Session, dosen_id: int, payload: DosenUpdate): + d = get_dosen_by_id(db, dosen_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(d, k, v) + db.commit(); db.refresh(d) + return d + +def delete_dosen(db: Session, dosen_id: int): + d = get_dosen_by_id(db, dosen_id) + db.delete(d); db.commit() diff --git a/facerecognition_presensi/backend/app/controllers/penawaran_controller.py b/facerecognition_presensi/backend/app/controllers/penawaran_controller.py new file mode 100644 index 0000000..144e35f --- /dev/null +++ b/facerecognition_presensi/backend/app/controllers/penawaran_controller.py @@ -0,0 +1,312 @@ +from fastapi import HTTPException +from sqlalchemy.orm import Session, joinedload +from app.models.penawaran_matakuliah import PenawaranMatakuliah, PenawaranKelas, Pengajar +from app.models.pengajar_pertemuan import PengajarPertemuan +from app.models.matakuliah import MataKuliah +from app.models.dosen import Dosen +from app.models.prodi import Prodi +from app.models.angkatan import Angkatan +from app.models.golongan import Golongan +from app.models.pertemuan import Pertemuan +from app.schemas.penawaran import ( + PenawaranCreate, PenawaranUpdate, + PenawaranKelasCreate, PenawaranKelasUpdate, + PengajarCreate, PengajarUpdate, + PengajarPertemuanCreate, +) + + +# ─── Helper: build response dict ───────────────────────────────────────────── + +def _penawaran_to_dict(p: PenawaranMatakuliah) -> dict: + return { + "id_penawaran": p.id_penawaran, + "id_matakuliah": p.id_matakuliah, + "kode_matakuliah": p.matakuliah.kode_matakuliah if p.matakuliah else None, + "nama_matakuliah": p.matakuliah.nama_matakuliah if p.matakuliah else None, + "semester": p.semester, + "tahun_ajaran": p.tahun_ajaran, + "status": p.status.value if p.status else "aktif", + "created_at": p.created_at, + "kelas_list": [_kelas_to_dict(k) for k in (p.penawaran_kelas_list or [])], + "pengajar_list": [_pengajar_to_dict(g) for g in (p.pengajar_list or [])], + } + + +def _kelas_to_dict(k: PenawaranKelas) -> dict: + return { + "id": k.id, + "id_penawaran": k.id_penawaran, + "id_prodi": k.id_prodi, + "id_angkatan": k.id_angkatan, + "id_golongan": k.id_golongan, + "kode_prodi": k.prodi.kode_prodi if k.prodi else None, + "tahun_angkatan": k.angkatan.tahun_angkatan if k.angkatan else None, + "nama_golongan": k.golongan.nama_golongan if k.golongan else None, + "nama_kelas": ( + f"{k.prodi.kode_prodi}-{k.angkatan.tahun_angkatan}-{k.golongan.nama_golongan}" + if k.prodi and k.angkatan and k.golongan else None + ), + } + + +def _pengajar_to_dict(g: Pengajar) -> dict: + return { + "id_pengajar": g.id_pengajar, + "id_penawaran": g.id_penawaran, + "id_dosen": g.id_dosen, + "role": g.role.value if g.role else "anggota", + "nama_dosen": g.dosen.nama_dosen if g.dosen else None, + "nidn": g.dosen.nidn if g.dosen else None, + } + + +def _pengajar_pertemuan_to_dict(pp: PengajarPertemuan) -> dict: + return { + "id": pp.id, + "id_pertemuan": pp.id_pertemuan, + "id_dosen": pp.id_dosen, + "nama_dosen": pp.dosen.nama_dosen if pp.dosen else None, + "nidn": pp.dosen.nidn if pp.dosen else None, + } + + +def _load_penawaran(db: Session, id_penawaran: int) -> PenawaranMatakuliah: + p = db.query(PenawaranMatakuliah).options( + joinedload(PenawaranMatakuliah.matakuliah), + joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.prodi), + joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.angkatan), + joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.golongan), + joinedload(PenawaranMatakuliah.pengajar_list).joinedload(Pengajar.dosen), + ).filter(PenawaranMatakuliah.id_penawaran == id_penawaran).first() + if not p: + raise HTTPException(404, "Penawaran mata kuliah tidak ditemukan") + return p + + +# ─── Penawaran Mata Kuliah CRUD ─────────────────────────────────────────────── + +def get_all_penawaran(db: Session, id_matakuliah=None, status_filter=None, skip=0, limit=100): + q = db.query(PenawaranMatakuliah).options( + joinedload(PenawaranMatakuliah.matakuliah), + joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.prodi), + joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.angkatan), + joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.golongan), + joinedload(PenawaranMatakuliah.pengajar_list).joinedload(Pengajar.dosen), + ) + if id_matakuliah: + q = q.filter(PenawaranMatakuliah.id_matakuliah == id_matakuliah) + if status_filter: + q = q.filter(PenawaranMatakuliah.status == status_filter) + return [_penawaran_to_dict(p) for p in q.offset(skip).limit(limit).all()] + + +def get_penawaran_by_id(db: Session, id_penawaran: int): + return _penawaran_to_dict(_load_penawaran(db, id_penawaran)) + + +def create_penawaran(db: Session, payload: PenawaranCreate): + if not db.query(MataKuliah).filter(MataKuliah.id_matakuliah == payload.id_matakuliah).first(): + raise HTTPException(404, "Mata kuliah tidak ditemukan") + p = PenawaranMatakuliah(**payload.model_dump()) + db.add(p) + db.commit() + db.refresh(p) + return _penawaran_to_dict(_load_penawaran(db, p.id_penawaran)) + + +def update_penawaran(db: Session, id_penawaran: int, payload: PenawaranUpdate): + p = db.query(PenawaranMatakuliah).filter(PenawaranMatakuliah.id_penawaran == id_penawaran).first() + if not p: + raise HTTPException(404, "Penawaran tidak ditemukan") + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(p, k, v) + db.commit() + return _penawaran_to_dict(_load_penawaran(db, id_penawaran)) + + +def delete_penawaran(db: Session, id_penawaran: int): + p = db.query(PenawaranMatakuliah).filter(PenawaranMatakuliah.id_penawaran == id_penawaran).first() + if not p: + raise HTTPException(404, "Penawaran tidak ditemukan") + db.delete(p) + db.commit() + + +# ─── Penawaran Kelas CRUD ───────────────────────────────────────────────────── + +def get_kelas_by_penawaran(db: Session, id_penawaran: int): + rows = db.query(PenawaranKelas).options( + joinedload(PenawaranKelas.prodi), + joinedload(PenawaranKelas.angkatan), + joinedload(PenawaranKelas.golongan), + ).filter(PenawaranKelas.id_penawaran == id_penawaran).all() + return [_kelas_to_dict(k) for k in rows] + + +def add_kelas_to_penawaran(db: Session, payload: PenawaranKelasCreate): + if not db.query(PenawaranMatakuliah).filter(PenawaranMatakuliah.id_penawaran == payload.id_penawaran).first(): + raise HTTPException(404, "Penawaran tidak ditemukan") + # Check duplicate + existing = db.query(PenawaranKelas).filter( + PenawaranKelas.id_penawaran == payload.id_penawaran, + PenawaranKelas.id_prodi == payload.id_prodi, + PenawaranKelas.id_angkatan == payload.id_angkatan, + PenawaranKelas.id_golongan == payload.id_golongan, + ).first() + if existing: + raise HTTPException(400, "Kelas sudah ada di penawaran ini") + k = PenawaranKelas(**payload.model_dump()) + db.add(k) + db.commit() + db.refresh(k) + # Reload with joins + k = db.query(PenawaranKelas).options( + joinedload(PenawaranKelas.prodi), + joinedload(PenawaranKelas.angkatan), + joinedload(PenawaranKelas.golongan), + ).filter(PenawaranKelas.id == k.id).first() + return _kelas_to_dict(k) + + +def add_kelas_to_penawaran_by_kelas_id(db: Session, id_penawaran: int, id_kelas: int): + """Tambah kelas ke penawaran menggunakan id_kelas langsung (dari tabel kelas).""" + from app.models.angkatan import Angkatan + from app.models.golongan import Golongan + from app.models.kelas import Kelas + + if not db.query(PenawaranMatakuliah).filter(PenawaranMatakuliah.id_penawaran == id_penawaran).first(): + raise HTTPException(404, "Penawaran tidak ditemukan") + + kelas = db.query(Kelas).filter(Kelas.id == id_kelas).first() + if not kelas: + raise HTTPException(404, "Kelas tidak ditemukan") + + ang_obj = None + if kelas.angkatan: + ang_obj = db.query(Angkatan).filter(Angkatan.tahun_angkatan == kelas.angkatan).first() + gol_obj = None + if kelas.golongan: + gol_obj = db.query(Golongan).filter(Golongan.nama_golongan == kelas.golongan).first() + + if not ang_obj: + raise HTTPException(400, "Data angkatan pada kelas tidak valid") + if not gol_obj: + raise HTTPException(400, "Data golongan pada kelas tidak valid") + + existing = db.query(PenawaranKelas).filter( + PenawaranKelas.id_penawaran == id_penawaran, + PenawaranKelas.id_prodi == kelas.id_prodi, + PenawaranKelas.id_angkatan == ang_obj.id_angkatan, + PenawaranKelas.id_golongan == gol_obj.id_golongan, + ).first() + if existing: + raise HTTPException(400, "Kelas sudah ada di penawaran ini") + + pk = PenawaranKelas( + id_penawaran=id_penawaran, + id_prodi=kelas.id_prodi, + id_angkatan=ang_obj.id_angkatan, + id_golongan=gol_obj.id_golongan, + ) + db.add(pk) + db.commit() + db.refresh(pk) + pk = db.query(PenawaranKelas).options( + joinedload(PenawaranKelas.prodi), + joinedload(PenawaranKelas.angkatan), + joinedload(PenawaranKelas.golongan), + ).filter(PenawaranKelas.id == pk.id).first() + return _kelas_to_dict(pk) + + +def remove_kelas_from_penawaran(db: Session, kelas_id: int): + k = db.query(PenawaranKelas).filter(PenawaranKelas.id == kelas_id).first() + if not k: + raise HTTPException(404, "Kelas tidak ditemukan") + db.delete(k) + db.commit() + + +# ─── Pengajar CRUD ──────────────────────────────────────────────────────────── + +def get_pengajar_by_penawaran(db: Session, id_penawaran: int): + rows = db.query(Pengajar).options( + joinedload(Pengajar.dosen), + ).filter(Pengajar.id_penawaran == id_penawaran).all() + return [_pengajar_to_dict(g) for g in rows] + + +def add_pengajar_to_penawaran(db: Session, payload: PengajarCreate): + if not db.query(PenawaranMatakuliah).filter(PenawaranMatakuliah.id_penawaran == payload.id_penawaran).first(): + raise HTTPException(404, "Penawaran tidak ditemukan") + if not db.query(Dosen).filter(Dosen.id_dosen == payload.id_dosen).first(): + raise HTTPException(404, "Dosen tidak ditemukan") + existing = db.query(Pengajar).filter( + Pengajar.id_penawaran == payload.id_penawaran, + Pengajar.id_dosen == payload.id_dosen, + ).first() + if existing: + raise HTTPException(400, "Dosen sudah terdaftar sebagai pengajar di penawaran ini") + g = Pengajar(**payload.model_dump()) + db.add(g) + db.commit() + db.refresh(g) + g = db.query(Pengajar).options(joinedload(Pengajar.dosen)).filter(Pengajar.id_pengajar == g.id_pengajar).first() + return _pengajar_to_dict(g) + + +def update_pengajar_role(db: Session, pengajar_id: int, payload: PengajarUpdate): + g = db.query(Pengajar).filter(Pengajar.id_pengajar == pengajar_id).first() + if not g: + raise HTTPException(404, "Pengajar tidak ditemukan") + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(g, k, v) + db.commit() + g = db.query(Pengajar).options(joinedload(Pengajar.dosen)).filter(Pengajar.id_pengajar == pengajar_id).first() + return _pengajar_to_dict(g) + + +def remove_pengajar(db: Session, pengajar_id: int): + g = db.query(Pengajar).filter(Pengajar.id_pengajar == pengajar_id).first() + if not g: + raise HTTPException(404, "Pengajar tidak ditemukan") + db.delete(g) + db.commit() + + +# ─── Pengajar Pertemuan CRUD ────────────────────────────────────────────────── + +def get_pengajar_pertemuan(db: Session, id_pertemuan: int): + rows = db.query(PengajarPertemuan).options( + joinedload(PengajarPertemuan.dosen), + ).filter(PengajarPertemuan.id_pertemuan == id_pertemuan).all() + return [_pengajar_pertemuan_to_dict(pp) for pp in rows] + + +def add_pengajar_pertemuan(db: Session, payload: PengajarPertemuanCreate): + from app.models.pertemuan import Pertemuan + if not db.query(Pertemuan).filter(Pertemuan.id_pertemuan == payload.id_pertemuan).first(): + raise HTTPException(404, "Pertemuan tidak ditemukan") + if not db.query(Dosen).filter(Dosen.id_dosen == payload.id_dosen).first(): + raise HTTPException(404, "Dosen tidak ditemukan") + existing = db.query(PengajarPertemuan).filter( + PengajarPertemuan.id_pertemuan == payload.id_pertemuan, + PengajarPertemuan.id_dosen == payload.id_dosen, + ).first() + if existing: + raise HTTPException(400, "Dosen sudah terdaftar sebagai pengajar di pertemuan ini") + pp = PengajarPertemuan(**payload.model_dump()) + db.add(pp) + db.commit() + db.refresh(pp) + pp = db.query(PengajarPertemuan).options(joinedload(PengajarPertemuan.dosen)).filter(PengajarPertemuan.id == pp.id).first() + return _pengajar_pertemuan_to_dict(pp) + + +def remove_pengajar_pertemuan(db: Session, pp_id: int): + pp = db.query(PengajarPertemuan).filter(PengajarPertemuan.id == pp_id).first() + if not pp: + raise HTTPException(404, "Pengajar pertemuan tidak ditemukan") + db.delete(pp) + db.commit() diff --git a/facerecognition_presensi/backend/app/controllers/presensi_controller.py b/facerecognition_presensi/backend/app/controllers/presensi_controller.py new file mode 100644 index 0000000..f21f4b2 --- /dev/null +++ b/facerecognition_presensi/backend/app/controllers/presensi_controller.py @@ -0,0 +1,382 @@ +from datetime import datetime, timezone + +from app.models.dataset_wajah import DatasetWajah, JenisFotoEnum +from app.models.log_deteksi import LogDeteksi +from app.models.mahasiswa import Mahasiswa +from app.models.pertemuan import Pertemuan, StatusPertemuanEnum +from app.models.presensi import Presensi, StatusPresensiEnum +from app.models.registrasi_wajah import RegistrasiWajah +from app.schemas.presensi import ( + DatasetWajahUpdate, + FaceRecognitionAbsenRequest, + LogDeteksiCreate, + LogDeteksiUpdate, + PresensiCreate, + PresensiUpdate, + RegistrasiWajahCreate, + RegistrasiWajahUpdate, + RekapPresensiResponse, +) +from fastapi import HTTPException +from sqlalchemy.orm import Session + +# ─── PRESENSI ───────────────────────────────────────────────────────────────── + + +def get_all_presensi( + db, id_pertemuan=None, id_mahasiswa=None, status_filter=None, skip=0, limit=100 +): + q = db.query(Presensi) + if id_pertemuan: + q = q.filter(Presensi.id_pertemuan == id_pertemuan) + if id_mahasiswa: + q = q.filter(Presensi.id_mahasiswa == id_mahasiswa) + if status_filter: + q = q.filter(Presensi.status_presensi == status_filter) + return q.offset(skip).limit(limit).all() + + +def get_presensi_by_id(db, presensi_id: int): + p = db.query(Presensi).filter(Presensi.id_presensi == presensi_id).first() + if not p: + raise HTTPException(404, "Presensi tidak ditemukan") + return p + + +def create_presensi_manual(db, payload: PresensiCreate): + if ( + not db.query(Pertemuan) + .filter(Pertemuan.id_pertemuan == payload.id_pertemuan) + .first() + ): + raise HTTPException(404, "Pertemuan tidak ditemukan") + if ( + not db.query(Mahasiswa) + .filter(Mahasiswa.id_mahasiswa == payload.id_mahasiswa) + .first() + ): + raise HTTPException(404, "Mahasiswa tidak ditemukan") + if ( + db.query(Presensi) + .filter( + Presensi.id_pertemuan == payload.id_pertemuan, + Presensi.id_mahasiswa == payload.id_mahasiswa, + ) + .first() + ): + raise HTTPException(400, "Presensi sudah tercatat") + p = Presensi(**payload.model_dump()) + db.add(p) + db.commit() + db.refresh(p) + return p + + +def absensi_face_recognition(db, payload: FaceRecognitionAbsenRequest): + pertemuan = ( + db.query(Pertemuan) + .filter(Pertemuan.id_pertemuan == payload.id_pertemuan) + .first() + ) + if not pertemuan: + raise HTTPException(404, "Pertemuan tidak ditemukan") + if pertemuan.status_pertemuan != StatusPertemuanEnum.sedang: + raise HTTPException(400, "Sesi absensi tidak sedang berlangsung") + mahasiswa = ( + db.query(Mahasiswa) + .filter(Mahasiswa.id_mahasiswa == payload.id_mahasiswa) + .first() + ) + if not mahasiswa: + raise HTTPException(404, "Mahasiswa tidak ditemukan") + + # ── Validasi Geolokasi ─────────────────────────────────────────────────── + jarak_meter = None + if pertemuan.mode_lokasi and pertemuan.mode_lokasi.value == "wajib": + if payload.latitude is None or payload.longitude is None: + raise HTTPException( + 400, + "Presensi ini memerlukan verifikasi lokasi. " + "Mohon aktifkan GPS dan coba lagi.", + ) + if pertemuan.id_geolokasi: + from app.controllers.geolokasi_controller import validasi_lokasi + from app.models.geolokasi import Geolokasi + + geo = ( + db.query(Geolokasi) + .filter(Geolokasi.id_geolokasi == pertemuan.id_geolokasi) + .first() + ) + if geo: + jarak_meter = validasi_lokasi(payload.latitude, payload.longitude, geo) + + import zoneinfo + waktu = datetime.now(zoneinfo.ZoneInfo("Asia/Jakarta")).replace(tzinfo=None) + # Tentukan status hadir/terlambat + status = StatusPresensiEnum.hadir + if pertemuan.batas_presensi: + batas = pertemuan.batas_presensi + if batas.tzinfo is not None: + batas = batas.replace(tzinfo=None) + if waktu > batas: + status = StatusPresensiEnum.terlambat + + existing = ( + db.query(Presensi) + .filter( + Presensi.id_pertemuan == payload.id_pertemuan, + Presensi.id_mahasiswa == payload.id_mahasiswa, + ) + .first() + ) + if existing: + if existing.status_presensi in ( + StatusPresensiEnum.belum, + StatusPresensiEnum.alpha, + ): + existing.status_presensi = status + existing.waktu_presensi = waktu + existing.confidence_score = payload.confidence_score + existing.lbph_score = payload.lbph_score + existing.cnn_score = payload.cnn_score + existing.metode_deteksi = payload.metode_deteksi + existing.foto_verifikasi = payload.foto_verifikasi + existing.latitude_presensi = payload.latitude + existing.longitude_presensi = payload.longitude + existing.jarak_meter = jarak_meter + db.commit() + db.refresh(existing) + return existing + raise HTTPException(400, "Mahasiswa sudah melakukan absensi") + + p = Presensi( + id_pertemuan=payload.id_pertemuan, + id_mahasiswa=payload.id_mahasiswa, + waktu_presensi=waktu, + status_presensi=status, + confidence_score=payload.confidence_score, + lbph_score=payload.lbph_score, + cnn_score=payload.cnn_score, + metode_deteksi=payload.metode_deteksi, + foto_verifikasi=payload.foto_verifikasi, + latitude_presensi=payload.latitude, + longitude_presensi=payload.longitude, + jarak_meter=jarak_meter, + ) + db.add(p) + db.commit() + db.refresh(p) + return p + + +def update_presensi(db, presensi_id: int, payload: PresensiUpdate): + p = get_presensi_by_id(db, presensi_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(p, k, v) + db.commit() + db.refresh(p) + return p + + +def delete_presensi(db, presensi_id: int): + p = get_presensi_by_id(db, presensi_id) + db.delete(p) + db.commit() + + +def rekap_per_pertemuan(db, pertemuan_id: int): + pertemuan = ( + db.query(Pertemuan).filter(Pertemuan.id_pertemuan == pertemuan_id).first() + ) + if not pertemuan: + raise HTTPException(404, "Pertemuan tidak ditemukan") + records = ( + db.query(Presensi, Mahasiswa) + .join(Mahasiswa, Presensi.id_mahasiswa == Mahasiswa.id_mahasiswa) + .filter(Presensi.id_pertemuan == pertemuan_id) + .all() + ) + return [ + { + "id_presensi": p.id_presensi, + "id_mahasiswa": m.id_mahasiswa, + "nim": m.nim, + "nama_mahasiswa": m.nama_mahasiswa, + "status_presensi": p.status_presensi.value, + "waktu_presensi": p.waktu_presensi, + "confidence_score": p.confidence_score, + "lbph_score": p.lbph_score, + "cnn_score": p.cnn_score, + } + for p, m in records + ] + + +def rekap_mahasiswa_per_jadwal(db, mahasiswa_id: int, jadwal_id: int): + mhs = db.query(Mahasiswa).filter(Mahasiswa.id_mahasiswa == mahasiswa_id).first() + if not mhs: + raise HTTPException(404, "Mahasiswa tidak ditemukan") + pertemuan_ids = [ + p.id_pertemuan + for p in db.query(Pertemuan).filter(Pertemuan.id_jadwal == jadwal_id).all() + ] + total = len(pertemuan_ids) + plist = ( + db.query(Presensi) + .filter( + Presensi.id_mahasiswa == mahasiswa_id, + Presensi.id_pertemuan.in_(pertemuan_ids), + ) + .all() + ) + hadir = sum(1 for p in plist if p.status_presensi.value == "hadir") + terlambat = sum(1 for p in plist if p.status_presensi.value == "terlambat") + izin = sum(1 for p in plist if p.status_presensi.value == "izin") + sakit = sum(1 for p in plist if p.status_presensi.value == "sakit") + alpha = sum(1 for p in plist if p.status_presensi.value == "alpha") + persen = round(((hadir + terlambat) / total * 100), 2) if total > 0 else 0.0 + return RekapPresensiResponse( + id_mahasiswa=mhs.id_mahasiswa, + nim=mhs.nim, + nama_mahasiswa=mhs.nama_mahasiswa, + total_hadir=hadir, + total_terlambat=terlambat, + total_izin=izin, + total_sakit=sakit, + total_alpha=alpha, + total_pertemuan=total, + persentase_kehadiran=persen, + ) + + +# ─── LOG DETEKSI ────────────────────────────────────────────────────────────── + + +def create_log_deteksi(db, payload: LogDeteksiCreate): + log = LogDeteksi(**payload.model_dump()) + db.add(log) + db.commit() + db.refresh(log) + return log + + +def get_all_log(db, id_mahasiswa=None, hasil_filter=None, skip=0, limit=100): + q = db.query(LogDeteksi) + if id_mahasiswa: + q = q.filter(LogDeteksi.id_mahasiswa == id_mahasiswa) + if hasil_filter: + q = q.filter(LogDeteksi.hasil_deteksi == hasil_filter) + return q.order_by(LogDeteksi.waktu_deteksi.desc()).offset(skip).limit(limit).all() + + +# ─── REGISTRASI WAJAH ───────────────────────────────────────────────────────── + + +def create_registrasi(db, payload: RegistrasiWajahCreate): + if ( + not db.query(Mahasiswa) + .filter(Mahasiswa.id_mahasiswa == payload.id_mahasiswa) + .first() + ): + raise HTTPException(404, "Mahasiswa tidak ditemukan") + jumlah_foto = ( + db.query(DatasetWajah) + .filter(DatasetWajah.id_mahasiswa == payload.id_mahasiswa) + .count() + ) + if jumlah_foto < 3: + raise HTTPException(400, f"Minimal 3 foto diperlukan (saat ini: {jumlah_foto})") + data = payload.model_dump() + data["jumlah_foto"] = jumlah_foto + reg = RegistrasiWajah(**data) + db.add(reg) + db.commit() + db.refresh(reg) + return reg + + + + +def update_registrasi(db, reg_id: int, payload: RegistrasiWajahUpdate): + reg = ( + db.query(RegistrasiWajah) + .filter(RegistrasiWajah.id_registrasi == reg_id) + .first() + ) + if not reg: + raise HTTPException(404, "Registrasi tidak ditemukan") + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(reg, k, v) + db.commit() + db.refresh(reg) + return reg + + +def get_registrasi_by_id(db, reg_id: int): + reg = ( + db.query(RegistrasiWajah) + .filter(RegistrasiWajah.id_registrasi == reg_id) + .first() + ) + if not reg: + raise HTTPException(404, "Registrasi tidak ditemukan") + return reg + + +def delete_registrasi(db, reg_id: int): + reg = ( + db.query(RegistrasiWajah) + .filter(RegistrasiWajah.id_registrasi == reg_id) + .first() + ) + if not reg: + raise HTTPException(404, "Registrasi tidak ditemukan") + db.delete(reg) + db.commit() + + +# ─── DATASET WAJAH ──────────────────────────────────────────────────────────── + + +def get_dataset_by_id(db, dataset_id: int): + d = db.query(DatasetWajah).filter(DatasetWajah.id_dataset == dataset_id).first() + if not d: + raise HTTPException(404, "Dataset wajah tidak ditemukan") + return d + + +def update_dataset_wajah(db, dataset_id: int, payload: DatasetWajahUpdate): + d = get_dataset_by_id(db, dataset_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(d, k, v) + db.commit() + db.refresh(d) + return d + + +# ─── LOG DETEKSI (tambahan) ─────────────────────────────────────────────────── + + +def get_log_by_id(db, log_id: int): + log = db.query(LogDeteksi).filter(LogDeteksi.id_log == log_id).first() + if not log: + raise HTTPException(404, "Log deteksi tidak ditemukan") + return log + + +def update_log_deteksi(db, log_id: int, payload: LogDeteksiUpdate): + log = get_log_by_id(db, log_id) + for k, v in payload.model_dump(exclude_none=True).items(): + setattr(log, k, v) + db.commit() + db.refresh(log) + return log + + +def delete_log_deteksi(db, log_id: int): + log = db.query(LogDeteksi).filter(LogDeteksi.id_log == log_id).first() + if not log: + raise HTTPException(404, "Log deteksi tidak ditemukan") + db.delete(log) + db.commit() diff --git a/facerecognition_presensi/backend/app/controllers/registrasi_controller.py b/facerecognition_presensi/backend/app/controllers/registrasi_controller.py new file mode 100644 index 0000000..e69de29 diff --git a/facerecognition_presensi/backend/app/main.py b/facerecognition_presensi/backend/app/main.py new file mode 100644 index 0000000..96162ca --- /dev/null +++ b/facerecognition_presensi/backend/app/main.py @@ -0,0 +1,211 @@ +# Matikan TF noise log (oneDNN) sebelum import apapun +import os +os.environ.setdefault("TF_ENABLE_ONEDNN_OPTS", "0") +os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "2") + +# Import all models so Base.metadata knows about them +import app.models # noqa: F401 +import logging +from contextlib import asynccontextmanager + + +from app.config.database import Base, engine +from app.config.settings import settings +from app.routes.auth import router as auth_router +from app.routes.compat_routes import ( + dashboard_router, + kelas_mk_router, + users_router, +) +from app.routes.dosen import ( + angkatan_router, + dosen_router, + golongan_router, + mk_router, + prodi_router, +) +from app.routes.jadwal import pertemuan_router +from app.routes.jadwal import router as jadwal_router +from app.routes.kelas import router as kelas_router +from app.routes.mahasiswa import router as mahasiswa_router +from app.routes.penawaran import ( + penawaran_kelas_router, + penawaran_router, + pengajar_pertemuan_router, + pengajar_router, +) +from app.routes.presensi import ( + dataset_router, + log_router, + registrasi_router, +) +from app.routes.presensi import ( + router as presensi_router, +) +from app.routes.geolokasi import router as geolokasi_router +from app.routes.face_recognition import router as face_recognition_router +from app.routes.arcface_enrollment import router as arcface_router +from app.routes.enhanced_recognition import router as enhanced_recognition_router +from app.routes.history_presensi import router as history_presensi_router +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +log = logging.getLogger(__name__) + + +def _init_database() -> bool: + """Create/check tables without preventing the API process from starting.""" + try: + Base.metadata.create_all(bind=engine) + log.info("[OK] Database schema checked") + return True + except Exception as exc: + log.warning("[DB] Schema init skipped/failed: %s", exc) + return False + + +# ── Lifespan: pre-load ML model saat startup ───────────────────────────────── +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Jalankan task startup & shutdown. + ML model di-load di sini agar siap sebelum request pertama masuk. + Ini menghindari cold-start delay saat /face-scan pertama dipanggil. + """ + import asyncio + + # STARTUP + log.info("=" * 60) + log.info(f" {settings.APP_NAME} v{settings.APP_VERSION}") + log.info(" Backend + ML (in-process) starting...") + log.info("=" * 60) + + _init_database() + + if settings.PRELOAD_ML_MODEL: + from app.services.ml_service import preload_ml_model + + ml_ok = preload_ml_model() + if ml_ok: + log.info("[OK] ML Face Recognition model loaded successfully") + else: + log.warning( + "[!] ML model gagal di-load. " + "Face-scan endpoint akan mencoba load ulang saat digunakan. " + "Pastikan dependencies ML (tensorflow, faiss-cpu, dll) terinstall." + ) + else: + log.info("[ML] Startup preload disabled. Model will load on first face-scan.") + + # ── Auto-archive presensi yang sudah lewat tenggat saat startup ──────── + try: + from app.config.database import SessionLocal + from app.controllers.jadwal_controller import archive_pertemuan_by_date + + db = SessionLocal() + try: + result = archive_pertemuan_by_date(db) + archived = result.get("archived_count", 0) + if archived > 0: + log.info(f"[OK] Auto-archived {archived} expired presensi on startup") + else: + log.info("[OK] No expired presensi to archive on startup") + finally: + db.close() + except Exception as exc: + log.warning(f"[!] Auto-archive on startup failed: {exc}") + + # ── Background scheduler: archive setiap 5 menit ───────────────────── + async def _periodic_archive(): + """Background task yang memeriksa dan meng-archive presensi expired setiap 5 menit.""" + while True: + await asyncio.sleep(300) # 5 menit + try: + db = SessionLocal() + try: + result = archive_pertemuan_by_date(db) + archived = result.get("archived_count", 0) + if archived > 0: + log.info(f"[SCHEDULER] Auto-archived {archived} expired presensi") + finally: + db.close() + except Exception as exc: + log.warning(f"[SCHEDULER] Periodic archive failed: {exc}") + + archive_task = asyncio.create_task(_periodic_archive()) + log.info("[OK] Periodic archive scheduler started (every 5 minutes)") + + log.info("[OK] Backend ready - http://0.0.0.0:8000") + log.info("[OK] API Docs - http://0.0.0.0:8000/api/docs") + + yield # ← server berjalan di sini + + # SHUTDOWN + archive_task.cancel() + try: + await archive_task + except asyncio.CancelledError: + pass + log.info("Backend shutting down...") + + +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", + lifespan=lifespan, +) + +# ── CORS ────────────────────────────────────────────────────────────────────── +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ── Register routers under /api/v1 ─────────────────────────────────────────── +API_PREFIX = "/api/v1" + +app.include_router(auth_router, prefix=API_PREFIX) +app.include_router(prodi_router, prefix=API_PREFIX) +app.include_router(angkatan_router, prefix=API_PREFIX) +app.include_router(golongan_router, prefix=API_PREFIX) +app.include_router(mk_router, prefix=API_PREFIX) +app.include_router(dosen_router, prefix=API_PREFIX) +app.include_router(mahasiswa_router, prefix=API_PREFIX) +app.include_router(jadwal_router, prefix=API_PREFIX) +app.include_router(pertemuan_router, prefix=API_PREFIX) +app.include_router(penawaran_router, prefix=API_PREFIX) +app.include_router(penawaran_kelas_router, prefix=API_PREFIX) +app.include_router(pengajar_router, prefix=API_PREFIX) +app.include_router(pengajar_pertemuan_router, prefix=API_PREFIX) +app.include_router(presensi_router, prefix=API_PREFIX) +app.include_router(dataset_router, prefix=API_PREFIX) +app.include_router(dataset_router, prefix=f"{API_PREFIX}/presensi") +app.include_router(registrasi_router, prefix=API_PREFIX) +app.include_router(registrasi_router, prefix=f"{API_PREFIX}/presensi") +app.include_router(log_router, prefix=API_PREFIX) + +app.include_router(geolokasi_router, prefix=API_PREFIX) +app.include_router(face_recognition_router, prefix=API_PREFIX) +app.include_router(arcface_router, prefix=API_PREFIX) +app.include_router(enhanced_recognition_router, prefix=API_PREFIX) +app.include_router(history_presensi_router, prefix=API_PREFIX) +app.include_router(kelas_router, prefix=API_PREFIX) +app.include_router(dashboard_router, prefix=API_PREFIX) +app.include_router(kelas_mk_router, prefix=API_PREFIX) +app.include_router(users_router, prefix=API_PREFIX) + + +@app.get("/", tags=["Health"]) +def root(): + return {"status": "ok", "app": settings.APP_NAME, "version": settings.APP_VERSION} + + +@app.get("/api/health", tags=["Health"]) +def health_check(): + return {"status": "healthy"} diff --git a/facerecognition_presensi/backend/app/models/__init__.py b/facerecognition_presensi/backend/app/models/__init__.py new file mode 100644 index 0000000..46ce369 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/__init__.py @@ -0,0 +1,45 @@ +# app/models/__init__.py -- import dalam urutan dependency +# Tier 1 -- no FK +from app.models.user import User # noqa: F401 +from app.models.prodi import Prodi # noqa: F401 +from app.models.dosen import Dosen # noqa: F401 +from app.models.matakuliah import MataKuliah # noqa: F401 +from app.models.angkatan import Angkatan # noqa: F401 +from app.models.golongan import Golongan # noqa: F401 +from app.models.geolokasi import Geolokasi # noqa: F401 + +# Tier 2 -- depends on Prodi, Angkatan, Golongan +from app.models.mahasiswa import Mahasiswa # noqa: F401 +from app.models.kelas import Kelas # noqa: F401 + +# Tier 3 -- depends on MataKuliah, Dosen, Prodi, Angkatan, Golongan +from app.models.penawaran_matakuliah import ( # noqa: F401 + PenawaranMatakuliah, PenawaranKelas, Pengajar, +) +from app.models.jadwal import Jadwal # noqa: F401 + +# Tier 4 -- depends on Jadwal +from app.models.pertemuan import Pertemuan # noqa: F401 + +# Tier 5 -- depends on Pertemuan, Dosen +from app.models.pengajar_pertemuan import PengajarPertemuan # noqa: F401 + +# Tier 6 -- depends on Mahasiswa +from app.models.dataset_wajah import DatasetWajah # noqa: F401 +from app.models.registrasi_wajah import RegistrasiWajah # noqa: F401 + +# Tier 7 -- depends on Pertemuan + Mahasiswa +from app.models.presensi import Presensi # noqa: F401 +from app.models.log_deteksi import LogDeteksi # noqa: F401 +from app.models.history_presensi import HistoryPresensi # noqa: F401 + +__all__ = [ + "User", "Prodi", "Dosen", "MataKuliah", "Angkatan", "Golongan", + "Geolokasi", + "Mahasiswa", "Kelas", + "PenawaranMatakuliah", "PenawaranKelas", "Pengajar", + "Jadwal", + "Pertemuan", "PengajarPertemuan", + "DatasetWajah", "RegistrasiWajah", + "Presensi", "LogDeteksi", "HistoryPresensi", +] diff --git a/facerecognition_presensi/backend/app/models/angkatan.py b/facerecognition_presensi/backend/app/models/angkatan.py new file mode 100644 index 0000000..ffaf372 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/angkatan.py @@ -0,0 +1,14 @@ +from sqlalchemy import BigInteger, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.config.database import Base + + +class Angkatan(Base): + __tablename__ = "angkatan" + + id_angkatan: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + tahun_angkatan: Mapped[int] = mapped_column(Integer, nullable=False) + + # Relationships + mahasiswa_list: Mapped[list["Mahasiswa"]] = relationship("Mahasiswa", back_populates="angkatan") + penawaran_kelas_list: Mapped[list["PenawaranKelas"]] = relationship("PenawaranKelas", back_populates="angkatan") diff --git a/facerecognition_presensi/backend/app/models/dataset_wajah.py b/facerecognition_presensi/backend/app/models/dataset_wajah.py new file mode 100644 index 0000000..26496fa --- /dev/null +++ b/facerecognition_presensi/backend/app/models/dataset_wajah.py @@ -0,0 +1,25 @@ +import enum +from sqlalchemy import BigInteger, String, Enum, SmallInteger, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from app.config.database import Base + + +class JenisFotoEnum(str, enum.Enum): + registrasi = "registrasi" + training = "training" + augmentasi = "augmentasi" + + +class DatasetWajah(Base): + __tablename__ = "dataset_wajah" + + id_dataset: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_mahasiswa: Mapped[int] = mapped_column(BigInteger, ForeignKey("mahasiswa.id_mahasiswa", ondelete="CASCADE"), nullable=False) + foto_path: Mapped[str] = mapped_column(String(255), nullable=False) + jenis_foto: Mapped[JenisFotoEnum] = mapped_column(Enum(JenisFotoEnum), nullable=False, default=JenisFotoEnum.registrasi) + digunakan_training: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now()) + + # Relationships + mahasiswa: Mapped["Mahasiswa"] = relationship("Mahasiswa", back_populates="dataset_wajah_list") diff --git a/facerecognition_presensi/backend/app/models/dosen.py b/facerecognition_presensi/backend/app/models/dosen.py new file mode 100644 index 0000000..bd46ddb --- /dev/null +++ b/facerecognition_presensi/backend/app/models/dosen.py @@ -0,0 +1,26 @@ +import enum +from sqlalchemy import BigInteger, String, Enum +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.config.database import Base + + +class StatusDosenEnum(str, enum.Enum): + aktif = "aktif" + nonaktif = "nonaktif" + + +class Dosen(Base): + __tablename__ = "dosen" + + id_dosen: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + nidn: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + nama_dosen: Mapped[str] = mapped_column(String(100), nullable=False) + email: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True) + no_hp: Mapped[str | None] = mapped_column(String(20), nullable=True) + status: Mapped[StatusDosenEnum] = mapped_column( + Enum(StatusDosenEnum), nullable=False, default=StatusDosenEnum.aktif + ) + + # Relationships + pengajar_list: Mapped[list["Pengajar"]] = relationship("Pengajar", back_populates="dosen") + pengajar_pertemuan_list: Mapped[list["PengajarPertemuan"]] = relationship("PengajarPertemuan", back_populates="dosen") diff --git a/facerecognition_presensi/backend/app/models/geolokasi.py b/facerecognition_presensi/backend/app/models/geolokasi.py new file mode 100644 index 0000000..abedd4d --- /dev/null +++ b/facerecognition_presensi/backend/app/models/geolokasi.py @@ -0,0 +1,32 @@ +import enum +from datetime import datetime +from sqlalchemy import BigInteger, DateTime, Enum, Integer, Numeric, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.config.database import Base + + +class StatusGeolokasiEnum(str, enum.Enum): + aktif = "aktif" + nonaktif = "nonaktif" + + +class Geolokasi(Base): + __tablename__ = "geolokasi" + + id_geolokasi: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + nama_lokasi: Mapped[str] = mapped_column(String(100), nullable=False) + deskripsi: Mapped[str | None] = mapped_column(Text, nullable=True) + latitude: Mapped[float] = mapped_column(Numeric(10, 8), nullable=False) + longitude: Mapped[float] = mapped_column(Numeric(11, 8), nullable=False) + radius_meter: Mapped[int] = mapped_column(Integer, nullable=False, default=100) + status: Mapped[StatusGeolokasiEnum] = mapped_column( + Enum(StatusGeolokasiEnum), nullable=False, default=StatusGeolokasiEnum.aktif + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, default=func.now(), onupdate=func.now() + ) + + # Relationships + pertemuan_list: Mapped[list["Pertemuan"]] = relationship( + "Pertemuan", back_populates="geolokasi", foreign_keys="Pertemuan.id_geolokasi" + ) diff --git a/facerecognition_presensi/backend/app/models/golongan.py b/facerecognition_presensi/backend/app/models/golongan.py new file mode 100644 index 0000000..4530c85 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/golongan.py @@ -0,0 +1,14 @@ +from sqlalchemy import BigInteger, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.config.database import Base + + +class Golongan(Base): + __tablename__ = "golongan" + + id_golongan: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + nama_golongan: Mapped[str] = mapped_column(String(50), nullable=False) + + # Relationships + mahasiswa_list: Mapped[list["Mahasiswa"]] = relationship("Mahasiswa", back_populates="golongan") + penawaran_kelas_list: Mapped[list["PenawaranKelas"]] = relationship("PenawaranKelas", back_populates="golongan") diff --git a/facerecognition_presensi/backend/app/models/history_presensi.py b/facerecognition_presensi/backend/app/models/history_presensi.py new file mode 100644 index 0000000..10beb9c --- /dev/null +++ b/facerecognition_presensi/backend/app/models/history_presensi.py @@ -0,0 +1,65 @@ +import enum +from datetime import datetime + +from app.config.database import Base +from sqlalchemy import ( + BigInteger, + DateTime, + Enum, + Float, + ForeignKey, + Numeric, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + + +class StatusHistoryPresensiEnum(str, enum.Enum): + hadir = "hadir" + terlambat = "terlambat" + izin = "izin" + sakit = "sakit" + alpha = "alpha" + + +class HistoryPresensi(Base): + __tablename__ = "history_presensi" + + id_history: Mapped[int] = mapped_column( + BigInteger, primary_key=True, autoincrement=True + ) + id_mahasiswa: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("mahasiswa.id_mahasiswa", ondelete="RESTRICT"), + nullable=False, + ) + id_pertemuan: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("pertemuan.id_pertemuan", ondelete="CASCADE"), + nullable=False, + ) + waktu_presensi: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status_presensi: Mapped[StatusHistoryPresensiEnum] = mapped_column( + Enum(StatusHistoryPresensiEnum), nullable=False + ) + foto_verifikasi: Mapped[str | None] = mapped_column(Text, nullable=True) + confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True) + lbph_score: Mapped[float | None] = mapped_column(Float, nullable=True) + cnn_score: Mapped[float | None] = mapped_column(Float, nullable=True) + metode_deteksi: Mapped[str | None] = mapped_column(String(50), nullable=True) + latitude_presensi: Mapped[float | None] = mapped_column( + Numeric(10, 8), nullable=True + ) + longitude_presensi: Mapped[float | None] = mapped_column( + Numeric(11, 8), nullable=True + ) + jarak_meter: Mapped[float | None] = mapped_column(Float, nullable=True) + created_at: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True, default=func.now(), server_default=func.now() + ) + + # Relationships + pertemuan: Mapped["Pertemuan"] = relationship("Pertemuan") + mahasiswa: Mapped["Mahasiswa"] = relationship("Mahasiswa") diff --git a/facerecognition_presensi/backend/app/models/jadwal.py b/facerecognition_presensi/backend/app/models/jadwal.py new file mode 100644 index 0000000..5d60821 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/jadwal.py @@ -0,0 +1,36 @@ +import enum +from sqlalchemy import BigInteger, String, Enum, Time, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import time +from app.config.database import Base + + +class HariEnum(str, enum.Enum): + senin = "Senin" + selasa = "Selasa" + rabu = "Rabu" + kamis = "Kamis" + jumat = "Jumat" + sabtu = "Sabtu" + minggu = "Minggu" + + +class StatusJadwalEnum(str, enum.Enum): + aktif = "aktif" + nonaktif = "nonaktif" + + +class Jadwal(Base): + __tablename__ = "jadwal" + + id_jadwal: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_penawaran: Mapped[int] = mapped_column(BigInteger, ForeignKey("penawaran_matakuliah.id_penawaran", ondelete="CASCADE"), nullable=False) + hari: Mapped[HariEnum] = mapped_column(Enum(HariEnum), nullable=False) + jam_mulai: Mapped[time] = mapped_column(Time, nullable=False) + jam_selesai: Mapped[time] = mapped_column(Time, nullable=False) + ruangan: Mapped[str | None] = mapped_column(String(50), nullable=True) + status: Mapped[StatusJadwalEnum] = mapped_column(Enum(StatusJadwalEnum), nullable=False, default=StatusJadwalEnum.aktif) + + # Relationships + penawaran: Mapped["PenawaranMatakuliah"] = relationship("PenawaranMatakuliah", back_populates="jadwal_list") + pertemuan_list: Mapped[list["Pertemuan"]] = relationship("Pertemuan", back_populates="jadwal", cascade="all, delete-orphan", passive_deletes=True) diff --git a/facerecognition_presensi/backend/app/models/kelas.py b/facerecognition_presensi/backend/app/models/kelas.py new file mode 100644 index 0000000..fd17d57 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/kelas.py @@ -0,0 +1,26 @@ +from app.config.database import Base +from sqlalchemy import BigInteger, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.models.mahasiswa import Mahasiswa + from app.models.prodi import Prodi + + +class Kelas(Base): + __tablename__ = "kelas" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_prodi: Mapped[int] = mapped_column( + BigInteger, ForeignKey("prodi.id_prodi", ondelete="RESTRICT"), nullable=False + ) + nama_kelas: Mapped[str] = mapped_column(String(100), nullable=False) + golongan: Mapped[str | None] = mapped_column(String(50), nullable=True) + angkatan: Mapped[int | None] = mapped_column(Integer, nullable=True) + tahun_ajaran: Mapped[str | None] = mapped_column(String(20), nullable=True) + keterangan: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Relationship (no back_populates needed on Prodi for this read-only join) + prodi: Mapped["Prodi"] = relationship("Prodi") + mahasiswa_list: Mapped[list["Mahasiswa"]] = relationship("Mahasiswa", back_populates="kelas", foreign_keys="[Mahasiswa.id_kelas]") diff --git a/facerecognition_presensi/backend/app/models/log_deteksi.py b/facerecognition_presensi/backend/app/models/log_deteksi.py new file mode 100644 index 0000000..96bb68b --- /dev/null +++ b/facerecognition_presensi/backend/app/models/log_deteksi.py @@ -0,0 +1,29 @@ +import enum +from sqlalchemy import BigInteger, String, Float, DateTime, Enum, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from app.config.database import Base + + +class HasilDeteksiEnum(str, enum.Enum): + match = "match" + unknown = "unknown" + no_face = "no_face" + error = "error" + + +class LogDeteksi(Base): + __tablename__ = "log_deteksi" + + id_log: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_mahasiswa: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("mahasiswa.id_mahasiswa", ondelete="SET NULL"), nullable=True) + waktu_deteksi: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=func.now(), server_default=func.now()) + lbph_score: Mapped[float | None] = mapped_column(Float, nullable=True) + cnn_score: Mapped[float | None] = mapped_column(Float, nullable=True) + confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True) + predicted_label: Mapped[str | None] = mapped_column(String(100), nullable=True) + hasil_deteksi: Mapped[HasilDeteksiEnum] = mapped_column(Enum(HasilDeteksiEnum), nullable=False) + foto_capture: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Relationships + mahasiswa: Mapped["Mahasiswa | None"] = relationship("Mahasiswa", back_populates="log_deteksi_list") diff --git a/facerecognition_presensi/backend/app/models/mahasiswa.py b/facerecognition_presensi/backend/app/models/mahasiswa.py new file mode 100644 index 0000000..3469db0 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/mahasiswa.py @@ -0,0 +1,46 @@ +import enum +from sqlalchemy import BigInteger, String, Enum, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from app.config.database import Base + + +class JenisKelaminEnum(str, enum.Enum): + L = "L" + P = "P" + + +class StatusMahasiswaEnum(str, enum.Enum): + aktif = "aktif" + nonaktif = "nonaktif" + lulus = "lulus" + cuti = "cuti" + + +class Mahasiswa(Base): + __tablename__ = "mahasiswa" + + id_mahasiswa: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + nim: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + nama_mahasiswa: Mapped[str] = mapped_column(String(100), nullable=False) + jenis_kelamin: Mapped[JenisKelaminEnum | None] = mapped_column(Enum(JenisKelaminEnum), nullable=True) + email: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True) + no_hp: Mapped[str | None] = mapped_column(String(20), nullable=True) + id_prodi: Mapped[int] = mapped_column(BigInteger, ForeignKey("prodi.id_prodi", ondelete="RESTRICT"), nullable=False) + id_angkatan: Mapped[int] = mapped_column(BigInteger, ForeignKey("angkatan.id_angkatan", ondelete="RESTRICT"), nullable=False) + id_golongan: Mapped[int] = mapped_column(BigInteger, ForeignKey("golongan.id_golongan", ondelete="RESTRICT"), nullable=False) + id_kelas: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("kelas.id", ondelete="SET NULL"), nullable=True) + foto_profil: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[StatusMahasiswaEnum] = mapped_column(Enum(StatusMahasiswaEnum), nullable=False, default=StatusMahasiswaEnum.aktif) + created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now(), onupdate=func.now()) + + # Relationships + prodi: Mapped["Prodi"] = relationship("Prodi", back_populates="mahasiswa_list") + angkatan: Mapped["Angkatan"] = relationship("Angkatan", back_populates="mahasiswa_list") + golongan: Mapped["Golongan"] = relationship("Golongan", back_populates="mahasiswa_list") + kelas: Mapped["Kelas"] = relationship("Kelas", back_populates="mahasiswa_list", foreign_keys=[id_kelas]) + presensi_list: Mapped[list["Presensi"]] = relationship("Presensi", back_populates="mahasiswa") + dataset_wajah_list: Mapped[list["DatasetWajah"]] = relationship("DatasetWajah", back_populates="mahasiswa") + registrasi_wajah_list: Mapped[list["RegistrasiWajah"]] = relationship("RegistrasiWajah", back_populates="mahasiswa") + log_deteksi_list: Mapped[list["LogDeteksi"]] = relationship("LogDeteksi", back_populates="mahasiswa") diff --git a/facerecognition_presensi/backend/app/models/matakuliah.py b/facerecognition_presensi/backend/app/models/matakuliah.py new file mode 100644 index 0000000..c88995a --- /dev/null +++ b/facerecognition_presensi/backend/app/models/matakuliah.py @@ -0,0 +1,15 @@ +from sqlalchemy import BigInteger, String, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.config.database import Base + + +class MataKuliah(Base): + __tablename__ = "matakuliah" + + id_matakuliah: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + kode_matakuliah: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + nama_matakuliah: Mapped[str] = mapped_column(String(100), nullable=False) + sks: Mapped[int] = mapped_column(Integer, nullable=False) + + # Relationships + penawaran_list: Mapped[list["PenawaranMatakuliah"]] = relationship("PenawaranMatakuliah", back_populates="matakuliah", cascade="all, delete-orphan", passive_deletes=True) diff --git a/facerecognition_presensi/backend/app/models/penawaran_matakuliah.py b/facerecognition_presensi/backend/app/models/penawaran_matakuliah.py new file mode 100644 index 0000000..2b12fa4 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/penawaran_matakuliah.py @@ -0,0 +1,67 @@ +import enum +from sqlalchemy import BigInteger, Integer, String, Enum, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from app.config.database import Base + + +class StatusPenawaranEnum(str, enum.Enum): + aktif = "aktif" + nonaktif = "nonaktif" + + +class RolePengajarEnum(str, enum.Enum): + koordinator = "koordinator" + anggota = "anggota" + + +# ─── Penawaran Mata Kuliah ──────────────────────────────────────────────────── + +class PenawaranMatakuliah(Base): + __tablename__ = "penawaran_matakuliah" + + id_penawaran: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_matakuliah: Mapped[int] = mapped_column(BigInteger, ForeignKey("matakuliah.id_matakuliah", ondelete="CASCADE"), nullable=False) + semester: Mapped[int] = mapped_column(Integer, nullable=False) + tahun_ajaran: Mapped[str] = mapped_column(String(20), nullable=False) + status: Mapped[StatusPenawaranEnum] = mapped_column(Enum(StatusPenawaranEnum), nullable=False, default=StatusPenawaranEnum.aktif) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + matakuliah: Mapped["MataKuliah"] = relationship("MataKuliah", back_populates="penawaran_list") + penawaran_kelas_list: Mapped[list["PenawaranKelas"]] = relationship("PenawaranKelas", back_populates="penawaran", cascade="all, delete-orphan") + pengajar_list: Mapped[list["Pengajar"]] = relationship("Pengajar", back_populates="penawaran", cascade="all, delete-orphan") + jadwal_list: Mapped[list["Jadwal"]] = relationship("Jadwal", back_populates="penawaran", cascade="all, delete-orphan", passive_deletes=True) + + +# ─── Penawaran Kelas ────────────────────────────────────────────────────────── + +class PenawaranKelas(Base): + __tablename__ = "penawaran_kelas" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_penawaran: Mapped[int] = mapped_column(BigInteger, ForeignKey("penawaran_matakuliah.id_penawaran", ondelete="CASCADE"), nullable=False) + id_prodi: Mapped[int] = mapped_column(BigInteger, ForeignKey("prodi.id_prodi", ondelete="RESTRICT"), nullable=False) + id_angkatan: Mapped[int] = mapped_column(BigInteger, ForeignKey("angkatan.id_angkatan", ondelete="RESTRICT"), nullable=False) + id_golongan: Mapped[int] = mapped_column(BigInteger, ForeignKey("golongan.id_golongan", ondelete="RESTRICT"), nullable=False) + + # Relationships + penawaran: Mapped["PenawaranMatakuliah"] = relationship("PenawaranMatakuliah", back_populates="penawaran_kelas_list") + prodi: Mapped["Prodi"] = relationship("Prodi", back_populates="penawaran_kelas_list") + angkatan: Mapped["Angkatan"] = relationship("Angkatan", back_populates="penawaran_kelas_list") + golongan: Mapped["Golongan"] = relationship("Golongan", back_populates="penawaran_kelas_list") + + +# ─── Pengajar ──────────────────────────────────────────────────────────────── + +class Pengajar(Base): + __tablename__ = "pengajar" + + id_pengajar: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_penawaran: Mapped[int] = mapped_column(BigInteger, ForeignKey("penawaran_matakuliah.id_penawaran", ondelete="CASCADE"), nullable=False) + id_dosen: Mapped[int] = mapped_column(BigInteger, ForeignKey("dosen.id_dosen", ondelete="RESTRICT"), nullable=False) + role: Mapped[RolePengajarEnum] = mapped_column(Enum(RolePengajarEnum), nullable=False, default=RolePengajarEnum.anggota) + + # Relationships + penawaran: Mapped["PenawaranMatakuliah"] = relationship("PenawaranMatakuliah", back_populates="pengajar_list") + dosen: Mapped["Dosen"] = relationship("Dosen", back_populates="pengajar_list") diff --git a/facerecognition_presensi/backend/app/models/pengajar_pertemuan.py b/facerecognition_presensi/backend/app/models/pengajar_pertemuan.py new file mode 100644 index 0000000..0dc0133 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/pengajar_pertemuan.py @@ -0,0 +1,15 @@ +from sqlalchemy import BigInteger, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.config.database import Base + + +class PengajarPertemuan(Base): + __tablename__ = "pengajar_pertemuan" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_pertemuan: Mapped[int] = mapped_column(BigInteger, ForeignKey("pertemuan.id_pertemuan", ondelete="CASCADE"), nullable=False) + id_dosen: Mapped[int] = mapped_column(BigInteger, ForeignKey("dosen.id_dosen", ondelete="RESTRICT"), nullable=False) + + # Relationships + pertemuan: Mapped["Pertemuan"] = relationship("Pertemuan", back_populates="pengajar_pertemuan_list") + dosen: Mapped["Dosen"] = relationship("Dosen", back_populates="pengajar_pertemuan_list") diff --git a/facerecognition_presensi/backend/app/models/pertemuan.py b/facerecognition_presensi/backend/app/models/pertemuan.py new file mode 100644 index 0000000..accf602 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/pertemuan.py @@ -0,0 +1,36 @@ +import enum +from sqlalchemy import BigInteger, Integer, Date, DateTime, Enum, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import date, datetime +from app.config.database import Base + + +class StatusPertemuanEnum(str, enum.Enum): + belum = "belum" + sedang = "sedang" + selesai = "selesai" + dibatalkan = "dibatalkan" + + +class ModeLokasikEnum(str, enum.Enum): + bebas = "bebas" + wajib = "wajib" + + +class Pertemuan(Base): + __tablename__ = "pertemuan" + + id_pertemuan: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_jadwal: Mapped[int] = mapped_column(BigInteger, ForeignKey("jadwal.id_jadwal", ondelete="CASCADE"), nullable=False) + pertemuan_ke: Mapped[int] = mapped_column(Integer, nullable=False) + tanggal: Mapped[date] = mapped_column(Date, nullable=False) + batas_presensi: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status_pertemuan: Mapped[StatusPertemuanEnum] = mapped_column(Enum(StatusPertemuanEnum), nullable=False, default=StatusPertemuanEnum.belum) + id_geolokasi: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("geolokasi.id_geolokasi", ondelete="SET NULL"), nullable=True) + mode_lokasi: Mapped[ModeLokasikEnum] = mapped_column(Enum(ModeLokasikEnum), nullable=False, default=ModeLokasikEnum.bebas) + + # Relationships + jadwal: Mapped["Jadwal"] = relationship("Jadwal", back_populates="pertemuan_list") + presensi_list: Mapped[list["Presensi"]] = relationship("Presensi", back_populates="pertemuan", cascade="all, delete-orphan") + pengajar_pertemuan_list: Mapped[list["PengajarPertemuan"]] = relationship("PengajarPertemuan", back_populates="pertemuan", cascade="all, delete-orphan") + geolokasi: Mapped["Geolokasi | None"] = relationship("Geolokasi", back_populates="pertemuan_list", foreign_keys=[id_geolokasi]) diff --git a/facerecognition_presensi/backend/app/models/presensi.py b/facerecognition_presensi/backend/app/models/presensi.py new file mode 100644 index 0000000..ef0c95b --- /dev/null +++ b/facerecognition_presensi/backend/app/models/presensi.py @@ -0,0 +1,68 @@ +import enum +from datetime import datetime + +from app.config.database import Base +from sqlalchemy import ( + BigInteger, + DateTime, + Enum, + Float, + ForeignKey, + Numeric, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + + +class StatusPresensiEnum(str, enum.Enum): + belum = "belum" # Belum presensi (initial state) + hadir = "hadir" + terlambat = "terlambat" + izin = "izin" + sakit = "sakit" + alpha = "alpha" + + +class Presensi(Base): + __tablename__ = "presensi" + + id_presensi: Mapped[int] = mapped_column( + BigInteger, primary_key=True, autoincrement=True + ) + id_mahasiswa: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("mahasiswa.id_mahasiswa", ondelete="RESTRICT"), + nullable=False, + ) + id_pertemuan: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("pertemuan.id_pertemuan", ondelete="CASCADE"), + nullable=False, + ) + waktu_presensi: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status_presensi: Mapped[StatusPresensiEnum] = mapped_column( + Enum(StatusPresensiEnum), nullable=False, default=StatusPresensiEnum.belum + ) + foto_verifikasi: Mapped[str | None] = mapped_column(Text, nullable=True) + confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True) + lbph_score: Mapped[float | None] = mapped_column(Float, nullable=True) + cnn_score: Mapped[float | None] = mapped_column(Float, nullable=True) + metode_deteksi: Mapped[str | None] = mapped_column(String(50), nullable=True) + # Geolocation audit columns + latitude_presensi: Mapped[float | None] = mapped_column( + Numeric(10, 8), nullable=True + ) + longitude_presensi: Mapped[float | None] = mapped_column( + Numeric(11, 8), nullable=True + ) + jarak_meter: Mapped[float | None] = mapped_column(Float, nullable=True) + + # Relationships + pertemuan: Mapped["Pertemuan"] = relationship( + "Pertemuan", back_populates="presensi_list" + ) + mahasiswa: Mapped["Mahasiswa"] = relationship( + "Mahasiswa", back_populates="presensi_list" + ) diff --git a/facerecognition_presensi/backend/app/models/prodi.py b/facerecognition_presensi/backend/app/models/prodi.py new file mode 100644 index 0000000..c5e0496 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/prodi.py @@ -0,0 +1,16 @@ +from sqlalchemy import BigInteger, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.config.database import Base + + +class Prodi(Base): + __tablename__ = "prodi" + + id_prodi: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + kode_prodi: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + nama_prodi: Mapped[str] = mapped_column(String(100), nullable=False) + jenjang: Mapped[str | None] = mapped_column(String(10), nullable=True) + + # Relationships + mahasiswa_list: Mapped[list["Mahasiswa"]] = relationship("Mahasiswa", back_populates="prodi") + penawaran_kelas_list: Mapped[list["PenawaranKelas"]] = relationship("PenawaranKelas", back_populates="prodi") diff --git a/facerecognition_presensi/backend/app/models/registrasi_wajah.py b/facerecognition_presensi/backend/app/models/registrasi_wajah.py new file mode 100644 index 0000000..f20a057 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/registrasi_wajah.py @@ -0,0 +1,25 @@ +import enum +from sqlalchemy import BigInteger, Integer, Float, Enum, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from app.config.database import Base + + +class StatusRegistrasiEnum(str, enum.Enum): + pending = "pending" + berhasil = "berhasil" + gagal = "gagal" + + +class RegistrasiWajah(Base): + __tablename__ = "registrasi_wajah" + + id_registrasi: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id_mahasiswa: Mapped[int] = mapped_column(BigInteger, ForeignKey("mahasiswa.id_mahasiswa", ondelete="CASCADE"), nullable=False) + jumlah_foto: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + similarity_check: Mapped[float | None] = mapped_column(Float, nullable=True) + status_registrasi: Mapped[StatusRegistrasiEnum] = mapped_column(Enum(StatusRegistrasiEnum), nullable=False, default=StatusRegistrasiEnum.pending) + created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now()) + + # Relationships + mahasiswa: Mapped["Mahasiswa"] = relationship("Mahasiswa", back_populates="registrasi_wajah_list") diff --git a/facerecognition_presensi/backend/app/models/user.py b/facerecognition_presensi/backend/app/models/user.py new file mode 100644 index 0000000..9e9f8d3 --- /dev/null +++ b/facerecognition_presensi/backend/app/models/user.py @@ -0,0 +1,22 @@ +import enum +from sqlalchemy import BigInteger, String, Enum +from sqlalchemy.orm import Mapped, mapped_column +from app.config.database import Base + + +class UserRoleEnum(str, enum.Enum): + admin = "admin" + dosen = "dosen" + mahasiswa = "mahasiswa" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + password: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRoleEnum] = mapped_column( + Enum(UserRoleEnum), nullable=False, default=UserRoleEnum.mahasiswa + ) diff --git a/facerecognition_presensi/backend/app/routes/arcface_enrollment.py b/facerecognition_presensi/backend/app/routes/arcface_enrollment.py new file mode 100644 index 0000000..9686269 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/arcface_enrollment.py @@ -0,0 +1,580 @@ +""" +arcface_enrollment.py +==================== +API routes untuk Face Enrollment dengan Model LBPH + MobileFaceNet (ModelBaru). + +Fitur: +- Enroll mahasiswa baru (1 foto) +- Extract embedding menggunakan LBPH + MobileFaceNet +- Quality-Aware Score-Level Fusion +- Simpan ke FAISS gallery +- Simpan foto ke face_mahasiswa/{nim}/ + +Path Configuration (ModelBaru): +- Backend: D:\PAJ TIF\Projek Skripsi\face_recognition_system\backend +- ModelBaru: D:\PAJ TIF\Projek Skripsi\face_recognition_system\ModelBaru +- Models: ModelBaru/models/ +- Enrollment Folder: ModelBaru/face_mahasiswa/{nim}/ + +Usage: + POST /api/v1/arcface/enroll - Enroll wajah mahasiswa + GET /api/v1/arcface/stats - Statistik sistem + GET /api/v1/arcface/list - List enrolled students + +Author: Face Recognition Skripsi +""" + +from __future__ import annotations + +import sys +import base64 +import logging +import pickle +from pathlib import Path +from typing import Optional, Dict, Any, List + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.config.database import get_db +from app.models.mahasiswa import Mahasiswa +from app.models.registrasi_wajah import RegistrasiWajah +from app.utils.dependencies import get_current_user, require_admin +from app.models.user import User + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/arcface", tags=["Face Enrollment"]) + +# ─── Path Configuration ──────────────────────────────────────────────────────── + +def _get_ml_paths(): + """ + Get ML paths dari ModelBaru folder. + """ + from app.config.settings import settings + + return { + "modelbaru_dir": settings.MODELBARU_DIR, + "models_dir": settings.MODELS_DIR, + "face_mahasiswa_dir": settings.FACE_MAHASISWA_DIR, + } + +# Initialize paths +_ML_PATHS = _get_ml_paths() + +# Add ModelBaru to sys.path for imports +if str(_ML_PATHS["modelbaru_dir"]) not in sys.path: + sys.path.insert(0, str(_ML_PATHS["modelbaru_dir"])) + +log.info(f"[Enrollment] ModelBaru dir: {_ML_PATHS['modelbaru_dir']}") +log.info(f"[Enrollment] Models dir: {_ML_PATHS['models_dir']}") + + +# ─── Request / Response Schemas ──────────────────────────────────────────────── + +class EnrollRequest(BaseModel): + """Request untuk enroll mahasiswa baru.""" + nim: str # NIM mahasiswa + foto_base64: str | List[str] # Foto wajah dalam base64 (single atau array) + force_update: bool = False # Update jika sudah ada + + +class EnrollResponse(BaseModel): + """Response hasil enroll.""" + success: bool + message: str + nim: Optional[str] = None + nama: Optional[str] = None + quality_score: Optional[float] = None + foto_path: Optional[str] = None + + +class StatsResponse(BaseModel): + """Response statistik sistem.""" + model_loaded: bool + total_identities: int + model_type: str + + +class EnrolledListResponse(BaseModel): + """Response list enrolled students.""" + success: bool + total: int + enrolled: list + + +# ─── Face Recognition System (ModelBaru - LBPH + MobileFaceNet) ───────────────── + +class EnhancedEnrollmentSystem: + """ + Enrollment system menggunakan LBPH + MobileFaceNet dari ModelBaru. + + Arsitektur: + - LBPH Extractor (640-D features) + - MobileFaceNet Extractor (512-D ONNX) + - Quality-Aware Score-Level Fusion + """ + + _instance = None + _system = None + _is_loaded = False + + @classmethod + def get_instance(cls): + """Get singleton instance.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def load(self) -> bool: + """Load Enhanced Face Recognition System dari ModelBaru.""" + if self._is_loaded: + return True + + log.info("[Enrollment] Loading Enhanced System (LBPH + MobileFaceNet)...") + + try: + # Import dari ModelBaru + from enhanced_system import EnhancedFaceRecognitionSystem + + self._system = EnhancedFaceRecognitionSystem() + self._is_loaded = True + log.info("[Enrollment] Enhanced System loaded successfully") + return True + + except ImportError as e: + log.error(f"[Enrollment] Failed to import ModelBaru system: {e}") + return False + except Exception as e: + log.error(f"[Enrollment] Failed to load system: {e}") + import traceback + traceback.print_exc() + return False + + def enroll(self, nim: str, frames: List, metadata: Dict = None) -> tuple: + """ + Enroll wajah mahasiswa. + + Args: + nim: NIM mahasiswa + frames: List of face images (numpy arrays) + metadata: Optional metadata + + Returns: + (success: bool, quality_score: float) + """ + if not self._is_loaded or self._system is None: + log.error("[Enrollment] System not loaded") + return False, 0.0 + + try: + # Extract quality score dari hasil enrollment + import numpy as np + + # Hitung quality score dari frame + quality_scores = [] + for frame in frames: + result = self._system.preprocessor.detect_and_preprocess(frame, for_enrollment=True) + if result['face'] is not None and result['quality']: + quality_scores.append(result['quality']['quality_score']) + + avg_quality = np.mean(quality_scores) if quality_scores else 0.5 + + # Enroll + success = self._system.enroll(nim, frames, metadata) + + return success, avg_quality + + except Exception as e: + log.error(f"[Enrollment] Enrollment failed: {e}") + import traceback + traceback.print_exc() + return False, 0.0 + + def get_stats(self) -> Dict[str, Any]: + """Get system statistics.""" + if not self._is_loaded or self._system is None: + return { + 'model_loaded': False, + 'total_identities': 0, + 'model_type': 'LBPH + MobileFaceNet (Enhanced)', + } + + return { + 'model_loaded': True, + 'total_identities': self._system.get_gallery_size(), + 'model_type': 'LBPH + MobileFaceNet (Enhanced)', + } + + def list_enrolled(self) -> list: + """List semua enrolled identities.""" + if not self._is_loaded or self._system is None: + return [] + + return self._system.gallery.list_identities() + + def is_enrolled(self, nim: str) -> bool: + """Check apakah NIM sudah enrolled.""" + if not self._is_loaded or self._system is None: + return False + + identities = self._system.gallery.list_identities() + return nim in identities + + +# ─── Helper Functions ─────────────────────────────────────────────────────────── + +def _decode_base64_to_image(foto_base64: str): + """Decode base64 string menjadi numpy array (BGR).""" + import cv2 + import numpy as np + + if "," in foto_base64: + foto_base64 = foto_base64.split(",", 1)[1] + + try: + img_bytes = base64.b64decode(foto_base64) + arr = np.frombuffer(img_bytes, np.uint8) + bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR) + + if bgr is None: + raise ValueError("Cannot decode image") + + return bgr + + except Exception as e: + raise HTTPException(400, f"Failed to decode image: {str(e)}") + + +def _save_photo_to_face_mahasiswa(nim: str, image, force_update: bool = False) -> Optional[str]: + """Simpan foto wajah ke folder face_mahasiswa/{nim}/ (standar resolusi 800x1066).""" + import os + import cv2 + import glob + + try: + face_mahasiswa_dir = _ML_PATHS["face_mahasiswa_dir"] + + # Resize ke ukuran standar gallery (width 800, height 1066) jika perlu + if image is not None: + h, w = image.shape[:2] + if w != 800 or h != 1066: + image = cv2.resize(image, (800, 1066), interpolation=cv2.INTER_AREA) + + # Buat folder jika belum ada + nim_folder = face_mahasiswa_dir / nim + nim_folder.mkdir(parents=True, exist_ok=True) + + # Jika force_update, hapus foto lama terlebih dahulu + if force_update: + existing_files = glob.glob(str(nim_folder / "*.jpg")) + for old_file in existing_files: + try: + os.remove(old_file) + log.info(f"[Enrollment] Deleted old photo: {old_file}") + except Exception as e: + log.warning(f"[Enrollment] Failed to delete old photo: {e}") + + # Tentukan nama file + existing_count = len(glob.glob(str(nim_folder / "*.jpg"))) + filename = f"foto_{existing_count + 1:03d}.jpg" + + filepath = nim_folder / filename + + # Simpan foto + success = cv2.imwrite(str(filepath), image) + + if success: + relative_path = f"face_mahasiswa/{nim}/{filename}" + log.info(f"[Enrollment] Foto disimpan: {relative_path}") + return relative_path + else: + log.error(f"[Enrollment] Gagal menyimpan foto ke {filepath}") + return None + + except Exception as e: + log.exception(f"[Enrollment] Error menyimpan foto: {e}") + return None + + +def _get_or_create_mahasiswa(db: Session, nim: str) -> Optional[Mahasiswa]: + """Get mahasiswa by NIM or return None.""" + return db.query(Mahasiswa).filter(Mahasiswa.nim == nim).first() + + +# ─── Endpoints ──────────────────────────────────────────────────────────────── + +@router.post("/enroll", response_model=EnrollResponse) +async def enroll_student( + payload: EnrollRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Enroll mahasiswa baru dengan LBPH + MobileFaceNet (ModelBaru). + + Alur: + 1. Decode foto base64 + 2. Simpan foto ke face_mahasiswa/{nim}/ + 3. Extract embedding menggunakan LBPH + MobileFaceNet + 4. Enroll ke FAISS gallery + 5. Update database + """ + # Find mahasiswa + mahasiswa = _get_or_create_mahasiswa(db, payload.nim) + if not mahasiswa: + return EnrollResponse( + success=False, + message=f"Mahasiswa dengan NIM {payload.nim} tidak ditemukan" + ) + + # Check apakah mahasiswa sudah ter-enroll + registrasi = db.query(RegistrasiWajah).filter( + RegistrasiWajah.id_mahasiswa == mahasiswa.id_mahasiswa + ).first() + + log.info(f"[Enrollment] Check: registrasi exists={registrasi is not None}, force_update={payload.force_update}") + + if registrasi and not payload.force_update: + return EnrollResponse( + success=False, + message=f"Mahasiswa {mahasiswa.nama_mahasiswa} sudah ter-enroll. " + f"Set force_update=true untuk override." + ) + + # Decode image(s) + try: + # Handle both single image and array of images + if isinstance(payload.foto_base64, list): + foto_list = payload.foto_base64 + else: + foto_list = [payload.foto_base64] + + bgr_images = [] + for foto in foto_list: + bgr_image = _decode_base64_to_image(foto) + bgr_images.append(bgr_image) + except HTTPException as e: + return EnrollResponse( + success=False, + message=f"Failed to decode image: {str(e)}" + ) + + # Simpan foto ke folder face_mahasiswa/{nim}/ + log.info(f"[Enrollment] Saving {len(bgr_images)} photo(s) with force_update={payload.force_update}") + foto_paths = [] + for idx, bgr_image in enumerate(bgr_images): + # idx=0: use original force_update value + # idx>0: always False (don't delete previously saved photos in this batch) + foto_path = _save_photo_to_face_mahasiswa(payload.nim, bgr_image, payload.force_update if idx == 0 else False) + if foto_path: + foto_paths.append(foto_path) + else: + log.error(f"[Enrollment] Failed to save photo {idx + 1}/{len(bgr_images)}") + + if not foto_paths: + return EnrollResponse( + success=False, + message="Gagal menyimpan foto wajah" + ) + + log.info(f"[Enrollment] {len(foto_paths)} photo(s) saved, proceeding to enrollment...") + + # Load enrollment system dan enroll + try: + enrollment_system = EnhancedEnrollmentSystem.get_instance() + + if not enrollment_system._is_loaded: + if not enrollment_system.load(): + return EnrollResponse( + success=False, + message="Gagal load Enhanced System. Pastikan ModelBaru terinstall dengan benar." + ) + + # Enroll to gallery + metadata = { + 'enrolled_by': current_user.name if current_user else 'unknown', + } + + ml_success, quality_score = enrollment_system.enroll( + nim=payload.nim, + frames=bgr_images, + metadata=metadata + ) + + # Get stats + stats = enrollment_system.get_stats() + + # Update database + from datetime import datetime + + if registrasi: + registrasi.jumlah_foto = len(bgr_images) + registrasi.status_registrasi = "berhasil" + else: + registrasi = RegistrasiWajah( + id_mahasiswa=mahasiswa.id_mahasiswa, + jumlah_foto=len(bgr_images), + similarity_check=float(quality_score), + status_registrasi="berhasil" + ) + db.add(registrasi) + + db.commit() + log.info(f"[Enrollment] Database updated for {mahasiswa.nim}") + + return EnrollResponse( + success=True, + message=f"Berhasil enroll {mahasiswa.nama_mahasiswa} dengan {len(bgr_images)} foto, Quality Score: {quality_score:.2f}", + nim=mahasiswa.nim, + nama=mahasiswa.nama_mahasiswa, + quality_score=float(quality_score), + foto_path=", ".join(foto_paths) + ) + + except Exception as e: + log.exception("[Enrollment] Enrollment error") + db.rollback() + return EnrollResponse( + success=False, + message=f"Enrollment error: {str(e)}" + ) + + +@router.get("/status/{nim}") +async def check_registration_status( + nim: str, + db: Session = Depends(get_db), +): + """ + Cek apakah mahasiswa sudah terdaftar wajahnya. + """ + try: + # Check di database + mahasiswa = db.query(Mahasiswa).filter(Mahasiswa.nim == nim).first() + if not mahasiswa: + return { + "is_registered": False, + "nim": nim, + "message": "Mahasiswa tidak ditemukan" + } + + # Check di tabel registrasi_wajah + registrasi = db.query(RegistrasiWajah).filter( + RegistrasiWajah.id_mahasiswa == mahasiswa.id_mahasiswa + ).first() + + is_registered = registrasi is not None and registrasi.status_registrasi == "berhasil" + + return { + "is_registered": is_registered, + "nim": nim, + "nama": mahasiswa.nama_mahasiswa, + "message": "Mahasiswa sudah terdaftar" if is_registered else "Mahasiswa belum terdaftar" + } + + except Exception as e: + log.exception("[Enrollment] Status check error") + return { + "is_registered": False, + "nim": nim, + "message": f"Error: {str(e)}" + } + + +@router.get("/stats", response_model=StatsResponse) +async def get_stats(): + """Get sistem statistics.""" + try: + enrollment_system = EnhancedEnrollmentSystem.get_instance() + + if not enrollment_system._is_loaded: + enrollment_system.load() + + stats = enrollment_system.get_stats() + + return StatsResponse( + model_loaded=stats.get('model_loaded', False), + total_identities=stats.get('total_identities', 0), + model_type=stats.get('model_type', 'LBPH + MobileFaceNet (Enhanced)'), + ) + + except Exception as e: + log.exception("[Enrollment] Stats error") + return StatsResponse( + model_loaded=False, + total_identities=0, + model_type="error", + ) + + +@router.get("/list", response_model=EnrolledListResponse) +async def list_enrolled(): + """List semua enrolled students.""" + try: + enrollment_system = EnhancedEnrollmentSystem.get_instance() + + if not enrollment_system._is_loaded: + enrollment_system.load() + + enrolled = enrollment_system.list_enrolled() + + return EnrolledListResponse( + success=True, + total=len(enrolled), + enrolled=enrolled + ) + + except Exception as e: + log.exception("[Enrollment] List error") + return EnrolledListResponse( + success=False, + total=0, + enrolled=[] + ) + + +@router.delete("/{nim}") +async def delete_enrollment( + nim: str, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + """ + Hapus enrollment mahasiswa berdasarkan NIM. + + Butuh akses admin. + """ + try: + # Find mahasiswa + mahasiswa = _get_or_create_mahasiswa(db, nim) + if not mahasiswa: + return {"success": False, "message": f"Mahasiswa dengan NIM {nim} tidak ditemukan"} + + # Hapus dari database + registrasi = db.query(RegistrasiWajah).filter( + RegistrasiWajah.id_mahasiswa == mahasiswa.id_mahasiswa + ).first() + + if registrasi: + db.delete(registrasi) + db.commit() + log.info(f"[Enrollment] Deleted database record for {nim}") + + # Opsional: hapus folder foto + import shutil + foto_folder = _ML_PATHS["face_mahasiswa_dir"] / nim + if foto_folder.exists(): + shutil.rmtree(foto_folder) + log.info(f"[Enrollment] Deleted photo folder: {foto_folder}") + + return { + "success": True, + "message": f"Enrollment {nim} berhasil dihapus", + } + + except Exception as e: + log.exception("[Enrollment] Delete error") + return {"success": False, "message": f"Delete error: {str(e)}"} diff --git a/facerecognition_presensi/backend/app/routes/auth.py b/facerecognition_presensi/backend/app/routes/auth.py new file mode 100644 index 0000000..a031ea5 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/auth.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from app.config.database import get_db +from app.models.user import User, UserRoleEnum +from app.schemas.master import UserCreate, UserResponse, TokenResponse, ChangePasswordRequest +from app.utils.security import get_password_hash, verify_password, create_access_token +from app.utils.dependencies import get_current_user, require_admin + +router = APIRouter(prefix="/auth", tags=["Auth"]) + + +@router.post("/login", response_model=TokenResponse, summary="Login dan dapatkan JWT token") +def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + user = db.query(User).filter(User.email == form_data.username).first() + if not user or not verify_password(form_data.password, user.password): + raise HTTPException(status_code=401, detail="Email atau password salah") + token = create_access_token({"sub": str(user.id), "role": user.role.value}) + return TokenResponse( + access_token=token, + user=UserResponse(id=user.id, name=user.name, email=user.email, role=user.role), + ) + + +@router.get("/me", response_model=UserResponse, summary="Profil user yang sedang login") +def get_me(current_user: User = Depends(get_current_user)): + return current_user + + +@router.post("/change-password", summary="Ganti password") +def change_password( + payload: ChangePasswordRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if not verify_password(payload.old_password, current_user.password): + raise HTTPException(status_code=400, detail="Password lama salah") + current_user.password = get_password_hash(payload.new_password) + db.commit() + return {"message": "Password berhasil diubah"} + + +@router.post("/register", response_model=UserResponse, status_code=201, summary="Register user baru (admin only)") +def register(payload: UserCreate, db: Session = Depends(get_db), _: User = Depends(require_admin)): + if db.query(User).filter(User.email == payload.email).first(): + raise HTTPException(status_code=400, detail="Email sudah terdaftar") + user = User( + name=payload.name, email=payload.email, + password=get_password_hash(payload.password), role=payload.role, + ) + db.add(user); db.commit(); db.refresh(user) + return user + + +@router.post("/logout", summary="Logout pengguna") +def logout(current_user: User = Depends(get_current_user)): + return {"message": "Logout berhasil", "status": "success"} + diff --git a/facerecognition_presensi/backend/app/routes/compat_routes.py b/facerecognition_presensi/backend/app/routes/compat_routes.py new file mode 100644 index 0000000..94d9d9a --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/compat_routes.py @@ -0,0 +1,469 @@ +""" +Compatibility / composite routes to bridge backend models with frontend expectations. +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime + +from app.config.database import get_db +from app.models.user import User, UserRoleEnum +from app.models.dosen import Dosen +from app.models.mahasiswa import Mahasiswa +from app.models.matakuliah import MataKuliah +from app.models.prodi import Prodi +from app.models.angkatan import Angkatan +from app.models.golongan import Golongan +from app.models.jadwal import Jadwal +from app.models.pertemuan import Pertemuan +from app.models.presensi import Presensi +from app.models.penawaran_matakuliah import PenawaranMatakuliah, PenawaranKelas, Pengajar +from app.utils.security import get_password_hash +from app.utils.dependencies import get_current_user, require_admin + +# ═══════════════════════════════════════════════════════════════════════════════ +# DASHBOARD +# ═══════════════════════════════════════════════════════════════════════════════ +dashboard_router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) + + +@dashboard_router.get("/") +def get_dashboard_stats(db: Session = Depends(get_db), _=Depends(get_current_user)): + total_jadwal = db.query(Jadwal).count() + total_mahasiswa = db.query(Mahasiswa).count() + total_matakuliah = db.query(MataKuliah).count() + total_dosen = db.query(Dosen).count() + + from datetime import date + today = date.today() + + presensi_hari_ini = 0 + try: + pertemuan_ids = db.query(Pertemuan.id_pertemuan).filter(Pertemuan.tanggal == today).all() + if pertemuan_ids: + pid_list = [p[0] for p in pertemuan_ids] + presensi_hari_ini = db.query(Presensi).filter(Presensi.id_pertemuan.in_(pid_list)).count() + except Exception: + presensi_hari_ini = 0 + + recent_presensi = [] + try: + pertemuan_with_presensi = db.query(Presensi.id_pertemuan).distinct().all() + if pertemuan_with_presensi: + pid_list = [p[0] for p in pertemuan_with_presensi] + recent_pertemuan = ( + db.query(Pertemuan) + .options( + joinedload(Pertemuan.jadwal).joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.matakuliah), + joinedload(Pertemuan.jadwal).joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.prodi), + joinedload(Pertemuan.jadwal).joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.angkatan), + joinedload(Pertemuan.jadwal).joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.golongan), + ) + .filter(Pertemuan.id_pertemuan.in_(pid_list)) + .order_by(Pertemuan.tanggal.desc()) + .limit(5) + .all() + ) + for p in recent_pertemuan: + j = p.jadwal + pen = j.penawaran if j else None + mk = pen.matakuliah if pen else None + kelas_names = [] + if pen: + for k in pen.penawaran_kelas_list: + if k.prodi and k.angkatan and k.golongan: + kelas_names.append(f"{k.prodi.kode_prodi}-{k.angkatan.tahun_angkatan}-{k.golongan.nama_golongan}") + hadir = db.query(Presensi).filter(Presensi.id_pertemuan == p.id_pertemuan, Presensi.status_presensi == "hadir").count() + alpha = db.query(Presensi).filter(Presensi.id_pertemuan == p.id_pertemuan, Presensi.status_presensi == "alpha").count() + total = db.query(Presensi).filter(Presensi.id_pertemuan == p.id_pertemuan).count() + recent_presensi.append({ + "pertemuan_ke": p.pertemuan_ke, + "tanggal": str(p.tanggal), + "nama_mk": mk.nama_matakuliah if mk else "-", + "kode_mk": mk.kode_matakuliah if mk else "-", + "kelas": ", ".join(kelas_names) if kelas_names else "-", + "hadir": hadir, + "alfa": alpha, + "total_mhs": total, + }) + except Exception: + recent_presensi = [] + + return { + "stats": { + "total_kelas_mk": total_jadwal, + "total_mahasiswa": total_mahasiswa, + "total_materi": total_matakuliah, + "presensi_hari_ini": presensi_hari_ini, + "total_dosen": total_dosen, + }, + "recent_presensi": recent_presensi, + } + + +# ═══════════════════════════════════════════════════════════════════════════════ +# KELAS MATA KULIAH (Jadwal + joined data) +# ═══════════════════════════════════════════════════════════════════════════════ +kelas_mk_router = APIRouter(prefix="/kelas-mata-kuliah", tags=["Kelas Mata Kuliah"]) + + +def _load_jadwal_full(db: Session, jadwal_id: int = None, query_filter=None): + q = db.query(Jadwal).options( + joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.matakuliah), + joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.prodi), + joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.angkatan), + joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.penawaran_kelas_list).joinedload(PenawaranKelas.golongan), + joinedload(Jadwal.penawaran).joinedload(PenawaranMatakuliah.pengajar_list).joinedload(Pengajar.dosen), + ) + if query_filter is not None: + q = q.filter(query_filter) + if jadwal_id is not None: + return q.filter(Jadwal.id_jadwal == jadwal_id).first() + return q.all() + + +def _jadwal_to_kelas_mk(j: Jadwal) -> dict: + pen = j.penawaran + mk = pen.matakuliah if pen else None + kelas_list = pen.penawaran_kelas_list if pen else [] + pengajar_list = pen.pengajar_list if pen else [] + + # For backwards compat — use first kelas as representative + first_kelas = kelas_list[0] if kelas_list else None + first_pengajar = pengajar_list[0] if pengajar_list else None + + kelas_names = [ + f"{k.prodi.kode_prodi}-{k.angkatan.tahun_angkatan}-{k.golongan.nama_golongan}" + for k in kelas_list if k.prodi and k.angkatan and k.golongan + ] + + return { + "id_kelas_mk": j.id_jadwal, + "id_jadwal": j.id_jadwal, + "id_penawaran": j.id_penawaran, + "kode_mk": mk.kode_matakuliah if mk else None, + "nama_mk": mk.nama_matakuliah if mk else None, + "id_matakuliah": pen.id_matakuliah if pen else None, + "semester": pen.semester if pen else None, + "tahun_ajaran": pen.tahun_ajaran if pen else None, + "nama_kelas": ", ".join(kelas_names) if kelas_names else None, + "kelas_list": [ + { + "id": k.id, + "id_prodi": k.id_prodi, + "id_angkatan": k.id_angkatan, + "id_golongan": k.id_golongan, + "kode_prodi": k.prodi.kode_prodi if k.prodi else None, + "tahun_angkatan": k.angkatan.tahun_angkatan if k.angkatan else None, + "nama_golongan": k.golongan.nama_golongan if k.golongan else None, + "nama_kelas": ( + f"{k.prodi.kode_prodi}-{k.angkatan.tahun_angkatan}-{k.golongan.nama_golongan}" + if k.prodi and k.angkatan and k.golongan else None + ), + } + for k in kelas_list + ], + "pengajar_list": [ + { + "id_pengajar": g.id_pengajar, + "id_dosen": g.id_dosen, + "role": g.role.value if g.role else "anggota", + "nama_dosen": g.dosen.nama_dosen if g.dosen else None, + "nidn": g.dosen.nidn if g.dosen else None, + } + for g in pengajar_list + ], + # Legacy compat fields (first dosen/kelas) + "prodi": first_kelas.prodi.kode_prodi if first_kelas and first_kelas.prodi else None, + "id_prodi": first_kelas.id_prodi if first_kelas else None, + "tahun_angkatan": first_kelas.angkatan.tahun_angkatan if first_kelas and first_kelas.angkatan else None, + "id_angkatan": first_kelas.id_angkatan if first_kelas else None, + "golongan": first_kelas.golongan.nama_golongan if first_kelas and first_kelas.golongan else None, + "id_golongan": first_kelas.id_golongan if first_kelas else None, + "id_dosen": first_pengajar.id_dosen if first_pengajar else None, + "nama_dosen": first_pengajar.dosen.nama_dosen if first_pengajar and first_pengajar.dosen else None, + "hari": j.hari.value if j.hari else None, + "jam_mulai": str(j.jam_mulai) if j.jam_mulai else None, + "jam_selesai": str(j.jam_selesai) if j.jam_selesai else None, + "ruangan": j.ruangan, + "status": j.status.value if j.status else "aktif", + } + + +@kelas_mk_router.get("/") +def list_all_kelas_mk(status_filter: Optional[str] = None, db: Session = Depends(get_db), _=Depends(get_current_user)): + items = _load_jadwal_full(db, query_filter=(Jadwal.status == status_filter) if status_filter else None) + if not isinstance(items, list): + items = [] + return [_jadwal_to_kelas_mk(j) for j in items] + + +@kelas_mk_router.get("/me") +def list_my_kelas_mk(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + items = _load_jadwal_full(db) + if not isinstance(items, list): + items = [] + + if current_user.role == UserRoleEnum.dosen: + dosen = db.query(Dosen).filter(Dosen.email == current_user.email).first() + if not dosen: + return [] + # Filter jadwal where this dosen is a pengajar + filtered = [ + j for j in items + if j.penawaran and any(g.id_dosen == dosen.id_dosen for g in j.penawaran.pengajar_list) + ] + return [_jadwal_to_kelas_mk(j) for j in filtered] + + elif current_user.role == UserRoleEnum.mahasiswa: + mhs = db.query(Mahasiswa).filter(Mahasiswa.email == current_user.email).first() + if not mhs: + return [] + # Filter jadwal where penawaran_kelas matches mahasiswa's prodi/angkatan/golongan + filtered = [ + j for j in items + if j.penawaran and any( + k.id_prodi == mhs.id_prodi and k.id_angkatan == mhs.id_angkatan and k.id_golongan == mhs.id_golongan + for k in j.penawaran.penawaran_kelas_list + ) + ] + return [_jadwal_to_kelas_mk(j) for j in filtered] + + # admin sees all + return [_jadwal_to_kelas_mk(j) for j in items] + + +@kelas_mk_router.get("/dosen/{id_dosen}") +def list_kelas_mk_by_dosen(id_dosen: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + items = _load_jadwal_full(db) + if not isinstance(items, list): + items = [] + filtered = [ + j for j in items + if j.penawaran and any(g.id_dosen == id_dosen for g in j.penawaran.pengajar_list) + ] + return [_jadwal_to_kelas_mk(j) for j in filtered] + + +@kelas_mk_router.get("/{id_kelas_mk}") +def detail_kelas_mk(id_kelas_mk: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + j = _load_jadwal_full(db, jadwal_id=id_kelas_mk) + if not j: + raise HTTPException(404, "Kelas mata kuliah tidak ditemukan") + return _jadwal_to_kelas_mk(j) + + +class KelasMKUpdate(BaseModel): + status: Optional[str] = None + hari: Optional[str] = None + jam_mulai: Optional[str] = None + jam_selesai: Optional[str] = None + ruangan: Optional[str] = None + + +@kelas_mk_router.put("/{id_kelas_mk}") +def update_kelas_mk(id_kelas_mk: int, payload: KelasMKUpdate, db: Session = Depends(get_db), _=Depends(require_admin)): + j = db.query(Jadwal).filter(Jadwal.id_jadwal == id_kelas_mk).first() + if not j: + raise HTTPException(404, "Kelas mata kuliah tidak ditemukan") + if payload.status: + j.status = payload.status + if payload.hari: + j.hari = payload.hari + if payload.ruangan is not None: + j.ruangan = payload.ruangan + db.commit() + j = _load_jadwal_full(db, jadwal_id=id_kelas_mk) + return _jadwal_to_kelas_mk(j) + + +@kelas_mk_router.delete("/{id_kelas_mk}", status_code=204) +def delete_kelas_mk(id_kelas_mk: int, db: Session = Depends(get_db), _=Depends(require_admin)): + j = db.query(Jadwal).filter(Jadwal.id_jadwal == id_kelas_mk).first() + if not j: + raise HTTPException(404, "Kelas mata kuliah tidak ditemukan") + db.delete(j) + db.commit() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# USERS (composite view: User + Dosen/Mahasiswa) +# ═══════════════════════════════════════════════════════════════════════════════ +users_router = APIRouter(prefix="/users", tags=["Users"]) + + +def _user_to_composite(user: User, db: Session) -> dict: + result = { + "id_user": user.id, + "nama": user.name, + "username": user.email, + "email": user.email, + "role": user.role.value, + } + if user.role == UserRoleEnum.dosen or user.role == UserRoleEnum.admin: + dosen = db.query(Dosen).filter(Dosen.email == user.email).first() + if dosen: + result["nip"] = dosen.nidn + result["nama_dosen"] = dosen.nama_dosen + result["email_dosen"] = dosen.email + result["no_hp"] = dosen.no_hp + result["status"] = dosen.status.value if dosen.status else "aktif" + result["id_dosen"] = dosen.id_dosen + elif user.role == UserRoleEnum.mahasiswa: + mhs = db.query(Mahasiswa).filter(Mahasiswa.email == user.email).first() + if mhs: + result["nim"] = mhs.nim + result["nama_mahasiswa"] = mhs.nama_mahasiswa + result["id_prodi"] = mhs.id_prodi + result["id_angkatan"] = mhs.id_angkatan + result["id_golongan"] = mhs.id_golongan + prodi = db.query(Prodi).filter(Prodi.id_prodi == mhs.id_prodi).first() + angk = db.query(Angkatan).filter(Angkatan.id_angkatan == mhs.id_angkatan).first() + gol = db.query(Golongan).filter(Golongan.id_golongan == mhs.id_golongan).first() + result["nama_kelas"] = f"{prodi.kode_prodi}-{angk.tahun_angkatan}-{gol.nama_golongan}" if prodi and angk and gol else None + result["id_mahasiswa"] = mhs.id_mahasiswa + return result + + +@users_router.get("/") +def list_all_users(db: Session = Depends(get_db), _=Depends(get_current_user)): + users = db.query(User).all() + return [_user_to_composite(u, db) for u in users] + + +@users_router.get("/{user_id}") +def get_user_detail(user_id: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "User tidak ditemukan") + return _user_to_composite(user, db) + + +class CreateUserComposite(BaseModel): + name: str + email: str + password: str + role: str + nidn: Optional[str] = None + nama_dosen: Optional[str] = None + no_hp: Optional[str] = None + nim: Optional[str] = None + nama_mahasiswa: Optional[str] = None + jenis_kelamin: Optional[str] = "L" + id_prodi: Optional[int] = None + id_angkatan: Optional[int] = None + id_golongan: Optional[int] = None + + +@users_router.post("/", status_code=201) +def create_user_composite(payload: CreateUserComposite, db: Session = Depends(get_db), _=Depends(require_admin)): + if db.query(User).filter(User.email == payload.email).first(): + raise HTTPException(400, "Email sudah terdaftar") + role_map = {"admin": UserRoleEnum.admin, "dosen": UserRoleEnum.dosen, "mahasiswa": UserRoleEnum.mahasiswa} + role_enum = role_map.get(payload.role, UserRoleEnum.mahasiswa) + try: + user = User(name=payload.name, email=payload.email, password=get_password_hash(payload.password), role=role_enum) + db.add(user) + db.flush() + if payload.role in ("admin", "dosen") and payload.nidn: + if db.query(Dosen).filter(Dosen.nidn == payload.nidn).first(): + db.rollback() + raise HTTPException(400, "NIDN sudah terdaftar") + db.add(Dosen(nidn=payload.nidn, nama_dosen=payload.nama_dosen or payload.name, email=payload.email, no_hp=payload.no_hp)) + elif payload.role == "mahasiswa" and payload.nim: + if db.query(Mahasiswa).filter(Mahasiswa.nim == payload.nim).first(): + db.rollback() + raise HTTPException(400, "NIM sudah terdaftar") + prodi_id = payload.id_prodi + angkatan_id = payload.id_angkatan + golongan_id = payload.id_golongan + if not prodi_id: + fp = db.query(Prodi).first() + if not fp: + fp = Prodi(kode_prodi="TIF", nama_prodi="Teknik Informatika", jenjang="S1") + db.add(fp); db.flush(); db.refresh(fp) + prodi_id = fp.id_prodi + if not angkatan_id: + fa = db.query(Angkatan).first() + if not fa: + fa = Angkatan(tahun_angkatan=2024) + db.add(fa); db.flush(); db.refresh(fa) + angkatan_id = fa.id_angkatan + if not golongan_id: + fg = db.query(Golongan).first() + if not fg: + fg = Golongan(nama_golongan="A") + db.add(fg); db.flush(); db.refresh(fg) + golongan_id = fg.id_golongan + db.add(Mahasiswa(nim=payload.nim, nama_mahasiswa=payload.nama_mahasiswa or payload.name, email=payload.email, id_prodi=prodi_id, id_angkatan=angkatan_id, id_golongan=golongan_id, jenis_kelamin=payload.jenis_kelamin or "L")) + db.commit() + db.refresh(user) + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(500, f"Gagal membuat user: {str(e)}") + return _user_to_composite(user, db) + + +class UpdateUserComposite(BaseModel): + name: Optional[str] = None + email: Optional[str] = None + role: Optional[str] = None + nidn: Optional[str] = None + nama_dosen: Optional[str] = None + no_hp: Optional[str] = None + nim: Optional[str] = None + nama_mahasiswa: Optional[str] = None + id_prodi: Optional[int] = None + id_angkatan: Optional[int] = None + id_golongan: Optional[int] = None + + +@users_router.put("/{user_id}") +def update_user_composite(user_id: int, payload: UpdateUserComposite, db: Session = Depends(get_db), _=Depends(require_admin)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "User tidak ditemukan") + old_email = user.email + + if user.role in (UserRoleEnum.admin, UserRoleEnum.dosen): + dosen = db.query(Dosen).filter(Dosen.email == old_email).first() + if dosen: + if payload.nama_dosen: dosen.nama_dosen = payload.nama_dosen + if payload.nidn: dosen.nidn = payload.nidn + if payload.no_hp is not None: dosen.no_hp = payload.no_hp + if payload.email: dosen.email = payload.email + elif user.role == UserRoleEnum.mahasiswa: + mhs = db.query(Mahasiswa).filter(Mahasiswa.email == old_email).first() + if mhs: + if payload.nama_mahasiswa: mhs.nama_mahasiswa = payload.nama_mahasiswa + if payload.nim: mhs.nim = payload.nim + if payload.id_prodi: mhs.id_prodi = payload.id_prodi + if payload.id_angkatan: mhs.id_angkatan = payload.id_angkatan + if payload.id_golongan: mhs.id_golongan = payload.id_golongan + if payload.email: mhs.email = payload.email + + if payload.name: + user.name = payload.name + if payload.email: + user.email = payload.email + db.commit() + db.refresh(user) + return _user_to_composite(user, db) + + +@users_router.delete("/{user_id}", status_code=204) +def delete_user_composite(user_id: int, db: Session = Depends(get_db), _=Depends(require_admin)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "User tidak ditemukan") + if user.role in (UserRoleEnum.admin, UserRoleEnum.dosen): + dosen = db.query(Dosen).filter(Dosen.email == user.email).first() + if dosen: db.delete(dosen) + elif user.role == UserRoleEnum.mahasiswa: + mhs = db.query(Mahasiswa).filter(Mahasiswa.email == user.email).first() + if mhs: db.delete(mhs) + db.delete(user) + db.commit() diff --git a/facerecognition_presensi/backend/app/routes/dosen.py b/facerecognition_presensi/backend/app/routes/dosen.py new file mode 100644 index 0000000..ef6e8c2 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/dosen.py @@ -0,0 +1,141 @@ +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session +from typing import List +from app.config.database import get_db +from app.models.user import User +from app.controllers.master_controller import ( + get_all_prodi, get_prodi_by_id, create_prodi, update_prodi, delete_prodi, + get_all_angkatan, get_angkatan_by_id, create_angkatan, update_angkatan, delete_angkatan, + get_all_golongan, get_golongan_by_id, create_golongan, update_golongan, delete_golongan, + get_all_matakuliah, get_matakuliah_by_id, create_matakuliah, update_matakuliah, delete_matakuliah, + get_all_dosen, get_dosen_by_id, get_dosen_by_nidn, create_dosen, update_dosen, delete_dosen, +) +from app.schemas.master import ( + ProdiCreate, ProdiUpdate, ProdiResponse, + AngkatanCreate, AngkatanUpdate, AngkatanResponse, + GolonganCreate, GolonganUpdate, GolonganResponse, + MataKuliahCreate, MataKuliahUpdate, MataKuliahResponse, +) +from app.schemas.dosen import DosenCreate, DosenUpdate, DosenResponse +from app.utils.dependencies import get_current_user, require_admin + +router = APIRouter() + +# ─── PRODI ──────────────────────────────────────────────────────────────────── +prodi_router = APIRouter(prefix="/prodi", tags=["Prodi"]) + +@prodi_router.get("/", response_model=List[ProdiResponse]) +def list_prodi(db=Depends(get_db), _=Depends(get_current_user)): + return get_all_prodi(db) + +@prodi_router.post("/", response_model=ProdiResponse, status_code=201) +def add_prodi(payload: ProdiCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_prodi(db, payload) + +@prodi_router.get("/{prodi_id}", response_model=ProdiResponse) +def detail_prodi(prodi_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_prodi_by_id(db, prodi_id) + +@prodi_router.put("/{prodi_id}", response_model=ProdiResponse) +def edit_prodi(prodi_id: int, payload: ProdiUpdate, db=Depends(get_db), _=Depends(require_admin)): + return update_prodi(db, prodi_id, payload) + +@prodi_router.delete("/{prodi_id}", status_code=204) +def hapus_prodi(prodi_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_prodi(db, prodi_id) + +# ─── ANGKATAN ───────────────────────────────────────────────────────────────── +angkatan_router = APIRouter(prefix="/angkatan", tags=["Angkatan"]) + +@angkatan_router.get("/", response_model=List[AngkatanResponse]) +def list_angkatan(db=Depends(get_db), _=Depends(get_current_user)): + return get_all_angkatan(db) + +@angkatan_router.post("/", response_model=AngkatanResponse, status_code=201) +def add_angkatan(payload: AngkatanCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_angkatan(db, payload) + +@angkatan_router.get("/{angkatan_id}", response_model=AngkatanResponse) +def detail_angkatan(angkatan_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_angkatan_by_id(db, angkatan_id) + +@angkatan_router.put("/{angkatan_id}", response_model=AngkatanResponse) +def edit_angkatan(angkatan_id: int, payload: AngkatanUpdate, db=Depends(get_db), _=Depends(require_admin)): + return update_angkatan(db, angkatan_id, payload) + +@angkatan_router.delete("/{angkatan_id}", status_code=204) +def hapus_angkatan(angkatan_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_angkatan(db, angkatan_id) + +# ─── GOLONGAN ───────────────────────────────────────────────────────────────── +golongan_router = APIRouter(prefix="/golongan", tags=["Golongan"]) + +@golongan_router.get("/", response_model=List[GolonganResponse]) +def list_golongan(db=Depends(get_db), _=Depends(get_current_user)): + return get_all_golongan(db) + +@golongan_router.post("/", response_model=GolonganResponse, status_code=201) +def add_golongan(payload: GolonganCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_golongan(db, payload) + +@golongan_router.get("/{golongan_id}", response_model=GolonganResponse) +def detail_golongan(golongan_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_golongan_by_id(db, golongan_id) + +@golongan_router.put("/{golongan_id}", response_model=GolonganResponse) +def edit_golongan(golongan_id: int, payload: GolonganUpdate, db=Depends(get_db), _=Depends(require_admin)): + return update_golongan(db, golongan_id, payload) + +@golongan_router.delete("/{golongan_id}", status_code=204) +def hapus_golongan(golongan_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_golongan(db, golongan_id) + +# ─── MATA KULIAH ────────────────────────────────────────────────────────────── +mk_router = APIRouter(prefix="/matakuliah", tags=["Mata Kuliah"]) + +@mk_router.get("/", response_model=List[MataKuliahResponse]) +def list_mk(db=Depends(get_db), _=Depends(get_current_user)): + return get_all_matakuliah(db) + +@mk_router.post("/", response_model=MataKuliahResponse, status_code=201) +def add_mk(payload: MataKuliahCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_matakuliah(db, payload) + +@mk_router.get("/{mk_id}", response_model=MataKuliahResponse) +def detail_mk(mk_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_matakuliah_by_id(db, mk_id) + +@mk_router.put("/{mk_id}", response_model=MataKuliahResponse) +def edit_mk(mk_id: int, payload: MataKuliahUpdate, db=Depends(get_db), _=Depends(require_admin)): + return update_matakuliah(db, mk_id, payload) + +@mk_router.delete("/{mk_id}", status_code=204) +def hapus_mk(mk_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_matakuliah(db, mk_id) + +# ─── DOSEN ──────────────────────────────────────────────────────────────────── +dosen_router = APIRouter(prefix="/dosen", tags=["Dosen"]) + +@dosen_router.get("/", response_model=List[DosenResponse]) +def list_dosen(status_filter: str | None = None, db=Depends(get_db), _=Depends(get_current_user)): + return get_all_dosen(db, status_filter) + +@dosen_router.post("/", response_model=DosenResponse, status_code=201) +def add_dosen(payload: DosenCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_dosen(db, payload) + +@dosen_router.get("/nidn/{nidn}", response_model=DosenResponse) +def cari_dosen_nidn(nidn: str, db=Depends(get_db), _=Depends(get_current_user)): + return get_dosen_by_nidn(db, nidn) + +@dosen_router.get("/{dosen_id}", response_model=DosenResponse) +def detail_dosen(dosen_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_dosen_by_id(db, dosen_id) + +@dosen_router.put("/{dosen_id}", response_model=DosenResponse) +def edit_dosen(dosen_id: int, payload: DosenUpdate, db=Depends(get_db), _=Depends(require_admin)): + return update_dosen(db, dosen_id, payload) + +@dosen_router.delete("/{dosen_id}", status_code=204) +def hapus_dosen(dosen_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_dosen(db, dosen_id) diff --git a/facerecognition_presensi/backend/app/routes/enhanced_recognition.py b/facerecognition_presensi/backend/app/routes/enhanced_recognition.py new file mode 100644 index 0000000..7a34459 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/enhanced_recognition.py @@ -0,0 +1,669 @@ +""" +enhanced_recognition.py +==================== +API routes untuk Enhanced Face Recognition System (ModelBaru). + +Arsitektur: +- Score-Level Fusion: LBPH + MobileFaceNet (ONNX) +- Quality-Aware Weighting +- FAISS Gallery +- Multi-frame enrollment + +Endpoints: +- POST /api/v1/enhanced/enroll - Enroll wajah mahasiswa (multi-frame) +- POST /api/v1/enhanced/recognize - Recognize wajah +- POST /api/v1/enhanced/verify - Verify terhadap NIM tertentu +- GET /api/v1/enhanced/stats - Statistik sistem +- GET /api/v1/enhanced/list - List enrolled students +- DELETE /api/v1/enhanced/{nim} - Hapus enrollment + +Author: Skripsi 2026 +""" + +from __future__ import annotations + +import base64 +import logging +from typing import Optional, List + +import numpy as np +import cv2 + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.config.database import get_db +from app.models.mahasiswa import Mahasiswa +from app.models.registrasi_wajah import RegistrasiWajah, StatusRegistrasiEnum +from app.models.user import User +from app.utils.dependencies import get_current_user, require_admin +from app.services.enhanced_face_service import get_enhanced_face_wrapper + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/enhanced", tags=["Enhanced Face Recognition"]) + + +# ─── Path Configuration ──────────────────────────────────────────────────────── + +def _get_paths(): + """Get paths for enhanced face system.""" + from app.config.settings import settings + return { + "modelbaruu_dir": settings.MODELBARU_DIR, + "models_dir": settings.MODELS_DIR, + "face_mahasiswa_dir": settings.FACE_MAHASISWA_DIR, + } + +_ML_PATHS = _get_paths() + + +# ─── Request / Response Schemas ──────────────────────────────────────────────── + +class EnrollRequest(BaseModel): + """Request untuk enroll dengan 1 foto (quality validated).""" + nim: str + foto_base64: str + force_update: bool = False + + +class EnrollResponse(BaseModel): + """Response hasil enroll.""" + success: bool + message: str + nim: Optional[str] = None + nama: Optional[str] = None + gallery_size: Optional[int] = None + quality_score: Optional[float] = None + quality_details: Optional[dict] = None + num_frames: Optional[int] = None + augmentation_count: Optional[int] = None + + +class CheckQualityRequest(BaseModel): + """Request untuk check kualitas foto.""" + foto_base64: str + + +class CheckQualityResponse(BaseModel): + """Response hasil quality check.""" + success: bool + face_detected: bool + quality_score: float + can_enroll: bool + quality_details: Optional[dict] = None + face_size: Optional[int] = None + bbox: Optional[list] = None + recommendations: Optional[List[str]] = None + message: str + + +class RecognizeRequest(BaseModel): + """Request untuk recognize wajah.""" + foto_base64: str + + +class RecognizeResponse(BaseModel): + """Response hasil recognize.""" + success: bool + is_known: bool + nim: Optional[str] = None + person_name: Optional[str] = None + confidence: float + cnn_similarity: float + lbph_similarity: float + final_score: float + message: str + threshold: float + top_k_results: Optional[List[dict]] = None + + +class VerifyRequest(BaseModel): + """Request untuk verify terhadap NIM.""" + foto_base64: str + nim: str + + +class VerifyResponse(BaseModel): + """Response hasil verify.""" + valid: bool + nim: str + similarity: float + threshold: float + message: str + + +class StatsResponse(BaseModel): + """Response statistik sistem.""" + model_loaded: bool + model_type: str + architecture: str + gallery_size: int + threshold: float + identities: List[str] + + +class EnrolledListResponse(BaseModel): + """Response list enrolled students.""" + success: bool + total: int + identities: List[dict] + + +# ─── Helper Functions ────────────────────────────────────────────────────────── + +def _decode_base64_to_bgr(foto_base64: str) -> np.ndarray: + """Decode base64 string menjadi BGR numpy array.""" + if "," in foto_base64: + foto_base64 = foto_base64.split(",", 1)[1] + + try: + img_bytes = base64.b64decode(foto_base64) + arr = np.frombuffer(img_bytes, np.uint8) + bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR) + + if bgr is None: + raise ValueError("Cannot decode image") + + return bgr + + except Exception as e: + raise HTTPException(400, f"Failed to decode image: {str(e)}") + + +def _decode_base64_list_to_frames(foto_list: List[str]) -> List[np.ndarray]: + """Decode list of base64 strings menjadi list of BGR images.""" + frames = [] + for i, foto in enumerate(foto_list): + try: + frame = _decode_base64_to_bgr(foto) + frames.append(frame) + except HTTPException: + log.warning(f"[Enhanced] Failed to decode frame {i+1}") + continue + + return frames + + +def _save_photos_to_face_mahasiswa(nim: str, foto_list: List[str]) -> Optional[List[str]]: + """Simpan foto wajah ke folder face_mahasiswa/{nim}/.""" + try: + face_mahasiswa_dir = _ML_PATHS["face_mahasiswa_dir"] + + # Buat folder jika belum ada + nim_folder = face_mahasiswa_dir / nim + nim_folder.mkdir(parents=True, exist_ok=True) + + saved_paths = [] + for i, foto in enumerate(foto_list): + try: + bgr = _decode_base64_to_bgr(foto) + + # Tentukan nama file + filename = f"foto_{i+1:03d}.jpg" + filepath = nim_folder / filename + + # Simpan foto + success = cv2.imwrite(str(filepath), bgr) + if success: + saved_paths.append(str(filepath)) + + except HTTPException: + log.warning(f"[Enhanced] Failed to save frame {i+1}") + continue + + return saved_paths if saved_paths else None + + except Exception as e: + log.exception(f"[Enhanced] Error saving photos: {e}") + return None + + +def _get_or_create_mahasiswa(db: Session, nim: str) -> Optional[Mahasiswa]: + """Get mahasiswa by NIM.""" + return db.query(Mahasiswa).filter(Mahasiswa.nim == nim).first() + + +# ─── Endpoints ──────────────────────────────────────────────────────────────── + +@router.post("/enroll", response_model=EnrollResponse) +async def enroll_student( + payload: EnrollRequest, + db: Session = Depends(get_db), +): + """ + Enroll mahasiswa baru dengan 1 foto berkualitas. + + Sistem akan: + 1. Validasi quality foto (blur, brightness, pose) + 2. Generate 10 augmented versions untuk stabilitas + 3. Extract embedding dan simpan ke gallery + + Quality threshold: Minimal 0.60 + """ + # Find mahasiswa + mahasiswa = _get_or_create_mahasiswa(db, payload.nim) + if not mahasiswa: + return EnrollResponse( + success=False, + message=f"Mahasiswa dengan NIM {payload.nim} tidak ditemukan" + ) + + # Check existing enrollment + registrasi = db.query(RegistrasiWajah).filter( + RegistrasiWajah.id_mahasiswa == mahasiswa.id_mahasiswa + ).first() + + if registrasi and not payload.force_update: + return EnrollResponse( + success=False, + message=f"Mahasiswa {mahasiswa.nama_mahasiswa} sudah ter-enroll. " + f"Set force_update=true untuk override." + ) + + # Decode image + try: + bgr_image = _decode_base64_to_bgr(payload.foto_base64) + except HTTPException: + return EnrollResponse( + success=False, + message="Gagal decode gambar" + ) + + # Save photo + saved_paths = _save_photos_to_face_mahasiswa(payload.nim, [payload.foto_base64]) + + # Enroll using enhanced system + try: + wrapper = get_enhanced_face_wrapper() + + if not wrapper._is_loaded: + if not wrapper.load(): + return EnrollResponse( + success=False, + message="Gagal load EnhancedFace system" + ) + + # Use enroll() which handles single photo with quality validation + result = wrapper.enroll(payload.nim, [bgr_image]) + + if result.get("success"): + # Update database + if registrasi: + registrasi.jumlah_foto = 1 + registrasi.similarity_check = result.get("quality_score", 0.0) + registrasi.status_registrasi = StatusRegistrasiEnum.berhasil + else: + registrasi = RegistrasiWajah( + id_mahasiswa=mahasiswa.id_mahasiswa, + jumlah_foto=1, + similarity_check=result.get("quality_score", 0.0), + status_registrasi=StatusRegistrasiEnum.berhasil + ) + db.add(registrasi) + + db.commit() + + return EnrollResponse( + success=True, + message=f"Berhasil enroll {mahasiswa.nama_mahasiswa} dengan 1 foto berkualitas!", + nim=mahasiswa.nim, + nama=mahasiswa.nama_mahasiswa, + gallery_size=result.get("gallery_size"), + quality_score=result.get("quality_score"), + quality_details=result.get("quality_details"), + num_frames=1, + augmentation_count=result.get("augmentation_count") + ) + else: + # Quality check failed + return EnrollResponse( + success=False, + message=result.get("message", "Enrollment failed"), + quality_score=result.get("quality_score"), + quality_details=result.get("quality_details") + ) + + except Exception as e: + log.exception("[Enhanced] Enrollment error") + db.rollback() + return EnrollResponse( + success=False, + message=f"Enrollment error: {str(e)}" + ) + + +@router.post("/check-quality", response_model=CheckQualityResponse) +async def check_photo_quality( + payload: CheckQualityRequest, +): + """ + Check kualitas foto sebelum enrollment. + + Returns: + - face_detected: Apakah wajah terdeteksi + - quality_score: Score 0-1 + - can_enroll: Apakah foto bisa digunakan untuk enrollment + - recommendations: Saran untuk memperbaiki quality + """ + try: + bgr_image = _decode_base64_to_bgr(payload.foto_base64) + except HTTPException: + return CheckQualityResponse( + success=False, + face_detected=False, + quality_score=0.0, + can_enroll=False, + message="Gagal decode gambar" + ) + + try: + wrapper = get_enhanced_face_wrapper() + + if not wrapper._is_loaded: + if not wrapper.load(): + return CheckQualityResponse( + success=False, + face_detected=False, + quality_score=0.0, + can_enroll=False, + message="System tidak tersedia" + ) + + result = wrapper.check_quality(bgr_image) + + return CheckQualityResponse( + success=True, + face_detected=result.get("face_detected", False), + quality_score=result.get("quality_score", 0.0), + can_enroll=result.get("can_enroll", False), + quality_details=result.get("quality_details"), + face_size=result.get("face_size"), + bbox=result.get("bbox"), + recommendations=result.get("recommendations", []), + message=result.get("message", "") + ) + + except Exception as e: + log.exception("[Enhanced] Quality check error") + return CheckQualityResponse( + success=False, + face_detected=False, + quality_score=0.0, + can_enroll=False, + message=f"Error: {str(e)}" + ) + + +@router.post("/recognize", response_model=RecognizeResponse) +async def recognize_face( + payload: RecognizeRequest, + current_user: User = Depends(get_current_user), +): + """ + Recognize wajah dalam gambar. + + Score-Level Fusion: + - LBPH features (640-D) + - MobileFaceNet embedding (512-D) via ONNX + - wCNN = QualityScore, wLBPH = 1 - QualityScore + + Returns Top-K results untuk analisis lebih lanjut. + """ + # Decode image + try: + bgr_image = _decode_base64_to_bgr(payload.foto_base64) + except HTTPException: + raise HTTPException(400, "Failed to decode image") + + # Recognize + try: + wrapper = get_enhanced_face_wrapper() + + if not wrapper._is_loaded: + if not wrapper.load(): + return RecognizeResponse( + success=False, + is_known=False, + confidence=0.0, + cnn_similarity=0.0, + lbph_similarity=0.0, + final_score=0.0, + message="EnhancedFace system not loaded", + threshold=0.0 + ) + + result = wrapper.recognize(bgr_image) + + return RecognizeResponse( + success=True, + is_known=result.get("is_known", False), + nim=result.get("nim"), + person_name=result.get("person_name"), + confidence=result.get("confidence", 0.0), + cnn_similarity=result.get("cnn_similarity", 0.0), + lbph_similarity=result.get("lbph_similarity", 0.0), + final_score=result.get("final_score", 0.0), + message=result.get("message", ""), + threshold=result.get("threshold", 0.0), + top_k_results=result.get("top_k_results") + ) + + except Exception as e: + log.exception("[Enhanced] Recognition error") + return RecognizeResponse( + success=False, + is_known=False, + confidence=0.0, + cnn_similarity=0.0, + lbph_similarity=0.0, + final_score=0.0, + message=f"Recognition error: {str(e)}", + threshold=0.0 + ) + + +@router.post("/verify", response_model=VerifyResponse) +async def verify_face( + payload: VerifyRequest, + current_user: User = Depends(get_current_user), +): + """ + Verify wajah terhadap NIM tertentu. + + Berguna untuk absensi - verifikasi bahwa wajah yang di-scan + 确实是 milik mahasiswa yang claim. + """ + # Decode image + try: + bgr_image = _decode_base64_to_bgr(payload.foto_base64) + except HTTPException: + raise HTTPException(400, "Failed to decode image") + + # Verify + try: + wrapper = get_enhanced_face_wrapper() + + if not wrapper._is_loaded: + if not wrapper.load(): + return VerifyResponse( + valid=False, + nim=payload.nim, + similarity=0.0, + threshold=0.0, + message="EnhancedFace system not loaded" + ) + + result = wrapper.verify(bgr_image, payload.nim) + + return VerifyResponse( + valid=result.get("valid", False), + nim=payload.nim, + similarity=result.get("similarity", 0.0), + threshold=result.get("threshold", 0.0), + message=result.get("message", "") + ) + + except Exception as e: + log.exception("[Enhanced] Verify error") + return VerifyResponse( + valid=False, + nim=payload.nim, + similarity=0.0, + threshold=0.0, + message=f"Verify error: {str(e)}" + ) + + +@router.get("/stats", response_model=StatsResponse) +async def get_stats(): + """Get statistik Enhanced Face Recognition System.""" + try: + wrapper = get_enhanced_face_wrapper() + + if not wrapper._is_loaded: + wrapper.load() + + health = wrapper.health() + + return StatsResponse( + model_loaded=health.get("model_loaded", False), + model_type=health.get("model_type", "unknown"), + architecture=health.get("architecture", "unknown"), + gallery_size=health.get("gallery_size", 0), + threshold=health.get("threshold", 0.0), + identities=health.get("identities", []) + ) + + except Exception as e: + log.exception("[Enhanced] Stats error") + return StatsResponse( + model_loaded=False, + model_type="error", + architecture="unknown", + gallery_size=0, + threshold=0.0, + identities=[] + ) + + +@router.get("/list", response_model=EnrolledListResponse) +async def list_identities(): + """List semua enrolled identities dengan info quality.""" + try: + wrapper = get_enhanced_face_wrapper() + + if not wrapper._is_loaded: + wrapper.load() + + identities = wrapper.list_identities() + result = [] + + for nim in identities: + info = wrapper.get_identity_info(nim) + if info: + result.append(info) + + return EnrolledListResponse( + success=True, + total=len(result), + identities=result + ) + + except Exception as e: + log.exception("[Enhanced] List error") + return EnrolledListResponse( + success=False, + total=0, + identities=[] + ) + + +@router.delete("/{nim}") +async def delete_enrollment( + nim: str, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + """ + Hapus enrollment mahasiswa berdasarkan NIM. + + Ini akan: + 1. Hapus dari gallery + 2. Hapus dari database + 3. Hapus folder foto + """ + try: + # Find mahasiswa + mahasiswa = _get_or_create_mahasiswa(db, nim) + if not mahasiswa: + return {"success": False, "message": f"Mahasiswa dengan NIM {nim} tidak ditemukan"} + + # Hapus dari gallery + wrapper = get_enhanced_face_wrapper() + if not wrapper._is_loaded: + wrapper.load() + + removed = wrapper.remove_identity(nim) + + # Hapus dari database + registrasi = db.query(RegistrasiWajah).filter( + RegistrasiWajah.id_mahasiswa == mahasiswa.id_mahasiswa + ).first() + + if registrasi: + db.delete(registrasi) + + db.commit() + log.info(f"[Enhanced] Deleted enrollment for {nim}") + + # Hapus folder foto + import shutil + foto_folder = _ML_PATHS["face_mahasiswa_dir"] / nim + if foto_folder.exists(): + shutil.rmtree(foto_folder) + log.info(f"[Enhanced] Deleted photo folder: {foto_folder}") + + return { + "success": True, + "message": f"Enrollment {nim} berhasil dihapus", + "removed_from_gallery": removed + } + + except Exception as e: + log.exception("[Enhanced] Delete error") + return {"success": False, "message": f"Delete error: {str(e)}"} + + +@router.get("/health") +async def health_check(): + """Check health status of Enhanced Face Recognition System.""" + try: + wrapper = get_enhanced_face_wrapper() + return wrapper.health() + except Exception as e: + return { + "model_loaded": False, + "error": str(e) + } + + +@router.post("/reload") +async def reload_model(): + """Reload EnhancedFaceRecognitionSystem.""" + try: + wrapper = get_enhanced_face_wrapper() + wrapper._is_loaded = False + wrapper._system = None + success = wrapper.load() + + return { + "success": success, + "message": "Model reloaded" if success else "Failed to reload model" + } + except Exception as e: + return { + "success": False, + "message": f"Reload error: {str(e)}" + } diff --git a/facerecognition_presensi/backend/app/routes/face_recognition.py b/facerecognition_presensi/backend/app/routes/face_recognition.py new file mode 100644 index 0000000..190e990 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/face_recognition.py @@ -0,0 +1,722 @@ +""" +face_recognition.py (Updated for New Embedding Model) +================================================ +Face Verification using LBPH + MobileNetV2 Embedding Model. + +Changes: +- Uses embedding_model.keras instead of hybrid_face_model.keras +- Embedding dimension: 256 +- FAISS index rebuilt with new embeddings +""" + +from __future__ import annotations + +import base64 +import logging +import pickle +import time +from datetime import datetime, timezone +from typing import Optional + +import numpy as np +import faiss +import cv2 + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.config.database import get_db +from app.models.mahasiswa import Mahasiswa +from app.models.pertemuan import Pertemuan, StatusPertemuanEnum +from app.models.presensi import Presensi, StatusPresensiEnum +from app.utils.dependencies import get_current_user +from app.models.user import User +from app.services.enhanced_face_service import get_enhanced_face_wrapper +from app.services.anti_spoof_service import get_anti_spoof_manager + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/presensi", tags=["Face Recognition Absensi"]) + +# ─── Path Configuration ──────────────────────────────────────────────────────── + +def _get_ml_paths(): + """Get ML paths for ModelBaru.""" + from app.config.settings import settings + return { + "models_dir": settings.MODELS_DIR, + "face_mahasiswa_dir": settings.FACE_MAHASISWA_DIR, + } + +_ML_PATHS = _get_ml_paths() + +# Config - must match ModelBaru config +IMG_SIZE_LBPH = 96 +IMG_SIZE_MOBILE = 224 +LBPH_DIM = 640 +CNN_DIM = 1280 +EMBEDDING_DIM = 256 +SIMILARITY_THRESHOLD = 0.35 # Lowered for new model + + +# ─── Singleton Face Recognition Engine ─────────────────────────────────────────── + +class FaceVerificationEngine: + """ + Singleton engine for face verification using new embedding model. + """ + _instance = None + _embedding_model = None + _mobilenet_extractor = None + _detector = None + _faiss_index = None + _metadata = {} + _is_loaded = False + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def load(self) -> bool: + """Load all ML components.""" + if self._is_loaded: + return True + + log.info("[FaceVerify] Loading Face Verification Engine (New Model)...") + + try: + import tensorflow as tf + from tensorflow.keras import layers, Model, Input + from tensorflow.keras.applications import MobileNetV2 + tf.get_logger().setLevel('ERROR') + + # Load embedding model + model_path = _ML_PATHS["models_dir"] / "embedding_model.keras" + if not model_path.exists(): + log.error(f"[FaceVerify] Embedding model not found: {model_path}") + return False + + # Custom L2 Normalize layer for loading + class L2Normalize(tf.keras.layers.Layer): + def call(self, inputs): + return tf.math.l2_normalize(inputs, axis=1) + def compute_output_shape(self, input_shape): + return input_shape + + self._embedding_model = tf.keras.models.load_model( + str(model_path), + compile=False, + custom_objects={'L2Normalize': L2Normalize} + ) + log.info(f"[FaceVerify] Embedding model loaded: {model_path}") + + # Load MobileNet feature extractor + base_mobile = MobileNetV2( + input_shape=(IMG_SIZE_MOBILE, IMG_SIZE_MOBILE, 3), + include_top=False, + weights='imagenet' + ) + base_mobile.trainable = False + mobile_input = Input(shape=(IMG_SIZE_MOBILE, IMG_SIZE_MOBILE, 3)) + x = base_mobile(mobile_input) + x = layers.GlobalAveragePooling2D()(x) + self._mobilenet_extractor = Model(inputs=mobile_input, outputs=x) + log.info("[FaceVerify] MobileNet extractor loaded") + + except Exception as e: + log.error(f"[FaceVerify] Failed to load models: {e}") + return False + + # Load face detector + yunet_path = _ML_PATHS["models_dir"] / "face_detection_yunet_2023mar.onnx" + if yunet_path.exists(): + try: + self._detector = cv2.FaceDetectorYN.create( + str(yunet_path), "", (320, 320), + score_threshold=0.2, nms_threshold=0.3, top_k=5 + ) + log.info("[FaceVerify] YuNet detector loaded") + except Exception as e: + log.warning(f"[FaceVerify] YuNet failed: {e}") + self._detector = None + else: + self._detector = None + + if self._detector is None: + haar_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml" + cascade = cv2.CascadeClassifier(haar_path) + if not cascade.empty(): + self._detector = cascade + log.info("[FaceVerify] Haar Cascade loaded") + + # Load FAISS index + try: + index_path = _ML_PATHS["models_dir"] / "face_embeddings.index" + metadata_path = _ML_PATHS["models_dir"] / "face_embeddings_metadata.pkl" + + if index_path.exists(): + self._faiss_index = faiss.read_index(str(index_path)) + log.info(f"[FaceVerify] FAISS index loaded: {self._faiss_index.ntotal} vectors") + + if metadata_path.exists(): + with open(metadata_path, 'rb') as f: + self._metadata = pickle.load(f) + log.info(f"[FaceVerify] Metadata loaded: {len(self._metadata)} entries") + else: + log.warning("[FaceVerify] FAISS index not found") + + except Exception as e: + log.error(f"[FaceVerify] Failed to load FAISS: {e}") + + self._is_loaded = True + log.info("[FaceVerify] Face Verification Engine loaded!") + return True + + def _extract_lbph_features(self, gray: np.ndarray) -> np.ndarray: + """Extract LBPH features (640-D).""" + h, w = gray.shape + grid_x, grid_y = 8, 8 + neighbors = 8 + radius = 1 + n_bins = neighbors + 2 + cell_h, cell_w = h // grid_y, w // grid_x + + # Compute LBP + lbp = np.zeros((h, w), dtype=np.uint8) + padded = np.pad(gray, radius, mode='edge') + + for i in range(radius, h + radius): + for j in range(radius, w + radius): + center = padded[i, j] + binary = 0 + for k in range(neighbors): + angle = 2 * np.pi * k / neighbors + ni = int(round(i + radius * np.sin(angle))) + nj = int(round(j + radius * np.cos(angle))) + if padded[ni, nj] >= center: + binary |= (1 << k) + lbp[i - radius, j - radius] = binary + + # Compute histograms + histograms = [] + for gy in range(grid_y): + for gx in range(grid_x): + y1, y2 = gy * cell_h, (gy + 1) * cell_h + x1, x2 = gx * cell_w, (gx + 1) * cell_w + cell = lbp[y1:y2, x1:x2] + hist, _ = np.histogram(cell, bins=n_bins, range=(0, n_bins)) + hist = hist.astype(np.float32) + hist /= (hist.sum() + 1e-7) + histograms.append(hist) + + return np.concatenate(histograms) + + def _preprocess_lbph(self, face: np.ndarray) -> np.ndarray: + """Preprocess for LBPH.""" + face = cv2.resize(face, (IMG_SIZE_LBPH, IMG_SIZE_LBPH)) + if len(face.shape) == 3: + gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) + else: + gray = face + + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(gray) + + # Extract LBPH features + lbph_features = self._extract_lbph_features(enhanced) + return lbph_features + + def _preprocess_mobilenet(self, face: np.ndarray) -> np.ndarray: + """Preprocess for MobileNetV2.""" + from tensorflow.keras.applications.mobilenet_v2 import preprocess_input + + face = cv2.resize(face, (IMG_SIZE_MOBILE, IMG_SIZE_MOBILE)) + face = cv2.cvtColor(face, cv2.COLOR_BGR2RGB) + face = face.astype(np.float32) + face = preprocess_input(face) + return face + + def extract_embedding(self, face_crop: np.ndarray) -> Optional[np.ndarray]: + """Extract embedding from face crop.""" + if self._embedding_model is None or self._mobilenet_extractor is None: + return None + + try: + # Preprocess + lbph_features = self._preprocess_lbph(face_crop) + mobile_input = self._preprocess_mobilenet(face_crop) + + # Get MobileNet features (1280-D) + cnn_features = self._mobilenet_extractor.predict( + np.expand_dims(mobile_input, 0), verbose=0 + )[0] + + # Get embedding (256-D) + embedding = self._embedding_model.predict( + [np.expand_dims(lbph_features, 0), np.expand_dims(cnn_features, 0)], + verbose=0 + )[0] + + return embedding.astype(np.float32) + + except Exception as e: + log.error(f"[FaceVerify] Embedding extraction failed: {e}") + return None + + def detect_face(self, image: np.ndarray) -> Optional[dict]: + """Detect face in image.""" + if self._detector is None: + return None + + h, w = image.shape[:2] + + # Try YuNet + if hasattr(self._detector, 'setInputSize'): + self._detector.setInputSize((w, h)) + _, faces = self._detector.detect(image) + + if faces is not None and len(faces) > 0: + best_idx = np.argmax(faces[:, 14]) + face = faces[best_idx] + x, y, bw, bh = map(int, face[:4]) + confidence = float(face[14]) + + if bw >= 50 and bh >= 50 and confidence >= 0.2: + face_crop = image[y:y+bh, x:x+bw] + return { + 'face_crop': face_crop, + 'bbox': (x, y, bw, bh), + 'confidence': confidence + } + + # Try Haar + else: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + gray = cv2.equalizeHist(gray) + faces = self._detector.detectMultiScale(gray, 1.1, 5, minSize=(50, 50)) + + if len(faces) > 0: + x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) + pad = int(0.1 * min(w, h)) + x1, y1 = max(0, x - pad), max(0, y - pad) + x2, y2 = min(image.shape[1], x + w + pad), min(image.shape[0], y + h + pad) + face_crop = image[y1:y2, x1:x2] + + return { + 'face_crop': face_crop, + 'bbox': (x1, y1, x2 - x1, y2 - y1), + 'confidence': 0.7 + } + + return None + + def verify_face(self, image: np.ndarray, nim: str) -> dict: + """Verify face against specific NIM using FAISS.""" + if self._faiss_index is None or self._faiss_index.ntotal == 0: + return { + 'valid': False, + 'nim': nim, + 'similarity': 0.0, + 'message': 'Sistem belum siap.' + } + + # Extract embedding + embedding = self.extract_embedding(image) + if embedding is None: + return { + 'valid': False, + 'nim': nim, + 'similarity': 0.0, + 'message': 'Gagal ekstrak wajah.' + } + + # Search in FAISS + query = embedding.reshape(1, -1).astype(np.float32) + faiss.normalize_L2(query) + + k = min(10, self._faiss_index.ntotal) + distances, indices = self._faiss_index.search(query, k) + + # Find best match + best_idx = indices[0][0] + best_dist = distances[0][0] + + if best_dist >= SIMILARITY_THRESHOLD: + entry = self._metadata.get(str(best_idx), {}) + person_name = entry.get('person_name', 'Unknown') + return { + 'valid': True, + 'nim': nim, + 'person_name': person_name, + 'similarity': float(best_dist), + 'message': f'Wajah terverifikasi: {person_name}' + } + + return { + 'valid': False, + 'nim': nim, + 'similarity': float(best_dist), + 'message': f'Similarity {best_dist:.2f} < threshold {SIMILARITY_THRESHOLD}' + } + + +# ─── Pydantic Models ──────────────────────────────────────────────────────────── + +from typing import Optional as Opt + +class FaceScanRequest(BaseModel): + # Frontend mengirim 'foto_base64', tapi backward-compatible dengan 'foto_wajah' + foto_base64: Opt[str] = None # field dari frontend + foto_wajah: Opt[str] = None # field lama (compat) + id_pertemuan: int + latitude: Opt[float] = None + longitude: Opt[float] = None + + @property + def foto(self) -> str: + """Ambil foto dari field manapun yang terisi.""" + val = self.foto_base64 or self.foto_wajah + if not val: + raise ValueError("foto_base64 atau foto_wajah harus diisi") + return val + + +class FaceVerifyResponse(BaseModel): + # Response kompatibel dengan FaceScanResult di frontend + success: bool + message: str + nama_mahasiswa: Opt[str] = None + nim: Opt[str] = None + status_presensi: Opt[str] = None + confidence: Opt[float] = None + lbph_score: Opt[float] = None # detail score LBPH + cnn_score: Opt[float] = None # detail score CNN + is_known: Opt[bool] = None + waktu_presensi: Opt[str] = None + inference_time_ms: Opt[float] = None + + +# ─── Endpoints ──────────────────────────────────────────────────────────────── + +@router.post("/face-scan", response_model=FaceVerifyResponse) +def face_scan_absensi( + request: FaceScanRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Verifikasi wajah untuk presensi. + """ + import time as _time + t_start = _time.time() + + # Validasi ada foto + try: + raw_foto = request.foto + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + # Decode base64 image + try: + foto_wajah = raw_foto.split(",", 1)[1] if "," in raw_foto else raw_foto + image_data = base64.b64decode(foto_wajah) + nparr = np.frombuffer(image_data, np.uint8) + bgr_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + if bgr_image is None: + raise HTTPException(status_code=400, detail="Invalid image data") + + except Exception as e: + raise HTTPException(status_code=400, detail=f"Image decode failed: {str(e)}") + + mahasiswa = db.query(Mahasiswa).filter(Mahasiswa.email == current_user.email).first() + if not mahasiswa: + if current_user.role.value in ("admin", "dosen"): + mahasiswa = db.query(Mahasiswa).first() + else: + raise HTTPException(status_code=404, detail="Mahasiswa not found") + + + nim = mahasiswa.nim + nama = mahasiswa.nama_mahasiswa + + wrapper = get_enhanced_face_wrapper() + if not wrapper._is_loaded and not wrapper.load(): + raise HTTPException(status_code=500, detail="Enhanced face recognition system not ready") + + # ── Anti-Spoofing Check ──────────────────────────────────────────────── + # Periksa apakah gambar adalah foto cetak/layar HP sebelum proses recognition. + # Jika terdeteksi FAKE → tolak presensi langsung. + # Jika anti-spoof tidak tersedia (ERROR) → tetap lanjut (graceful fallback). + anti_spoof_mgr = get_anti_spoof_manager() + if not anti_spoof_mgr._is_loaded: + anti_spoof_mgr.load() + + spoof_result = anti_spoof_mgr.predict(bgr_image) + spoof_label = spoof_result.get("label", "ERROR") + spoof_score = spoof_result.get("score", 0.0) + + log.info( + f"[face-scan][anti-spoof] NIM={nim} label={spoof_label} " + f"real_score={spoof_score:.4f} msg={spoof_result.get('message','')}" + ) + + if spoof_label == "FAKE": + # Blokir presensi — foto palsu/cetak/layar terdeteksi + return FaceVerifyResponse( + success=False, + message=( + "Terdeteksi foto palsu atau layar HP. " + "Presensi harus menggunakan wajah asli secara langsung!" + ), + nama_mahasiswa=nama, + nim=nim, + is_known=False, + confidence=float(spoof_score), + cnn_score=0.0, + lbph_score=0.0, + inference_time_ms=(_time.time() - t_start) * 1000, + ) + elif spoof_label == "NO_FACE": + # Wajah tidak terdeteksi oleh anti-spoof + return FaceVerifyResponse( + success=False, + message="Wajah tidak terdeteksi. Pastikan wajah terlihat jelas di kamera.", + nama_mahasiswa=nama, + nim=nim, + is_known=False, + confidence=0.0, + cnn_score=0.0, + lbph_score=0.0, + inference_time_ms=(_time.time() - t_start) * 1000, + ) + # label == 'REAL' atau 'ERROR' (fallback) → lanjut ke recognition + + # ── Gunakan recognize() bukan verify() ──────────────────────────────── + # recognize() memanfaatkan multi-template CNN matching (max-similarity) + # verify() hanya compare ke centroid sehingga skor lebih rendah ~10-15% + result = wrapper.recognize(bgr_image) + + t_end = _time.time() + inference_ms = (t_end - t_start) * 1000 + + # Map hasil recognize ke format sebelumnya + recognized_nim = result.get("nim") + is_match = result.get("is_known", False) and (recognized_nim == nim) + similarity = result.get("confidence", result.get("final_score", 0.0)) + cnn_sim = result.get("cnn_similarity", 0.0) + lbph_sim = result.get("lbph_similarity", 0.0) + threshold = result.get("threshold", 0.0) + msg = result.get("message", "") + + # Logging detail ke uvicorn console — mudah dimonitor + log.info( + f"[face-scan] NIM={nim} | recognized_as={recognized_nim} | match={is_match} | " + f"fused={similarity:.4f} | cnn={cnn_sim:.4f} | lbph={lbph_sim:.4f} | " + f"threshold={threshold:.4f} | inference={inference_ms:.0f}ms | img={bgr_image.shape}" + ) + + if is_match: + # Catat presensi jika berhasil + from datetime import datetime, timezone as tz + from app.models.pertemuan import Pertemuan, StatusPertemuanEnum + from app.models.presensi import Presensi, StatusPresensiEnum + + pertemuan = db.query(Pertemuan).filter(Pertemuan.id_pertemuan == request.id_pertemuan).first() + if not pertemuan: + raise HTTPException(status_code=404, detail="Pertemuan tidak ditemukan") + + # Cek apakah sudah ada record presensi yang BENAR-BENAR sudah dicatat + # (status selain "belum" = sudah scan sebelumnya) + existing_done = db.query(Presensi).filter( + Presensi.id_pertemuan == request.id_pertemuan, + Presensi.id_mahasiswa == mahasiswa.id_mahasiswa, + Presensi.status_presensi != StatusPresensiEnum.belum, + ).first() + + if existing_done: + # Sudah benar-benar presensi sebelumnya (hadir/terlambat/dll) + return FaceVerifyResponse( + success=False, + message="Anda sudah melakukan presensi untuk pertemuan ini", + nama_mahasiswa=nama, + nim=nim, + status_presensi=existing_done.status_presensi.value if existing_done.status_presensi else None, + confidence=float(similarity), + cnn_score=float(cnn_sim), + lbph_score=float(lbph_sim), + is_known=True, + inference_time_ms=inference_ms, + ) + + import zoneinfo + now = datetime.now(zoneinfo.ZoneInfo("Asia/Jakarta")).replace(tzinfo=None) + # Tentukan status presensi berdasarkan batas_presensi pertemuan + status = StatusPresensiEnum.hadir + if pertemuan.batas_presensi: + # batas_presensi mungkin naive datetime, bandingkan dengan aman + batas = pertemuan.batas_presensi + if batas.tzinfo is not None: + batas = batas.replace(tzinfo=None) + if now > batas: + status = StatusPresensiEnum.terlambat + + # Cek apakah ada record pre-populated dengan status "belum" + # Jika ada, update record tersebut (bukan insert baru) + existing_belum = db.query(Presensi).filter( + Presensi.id_pertemuan == request.id_pertemuan, + Presensi.id_mahasiswa == mahasiswa.id_mahasiswa, + Presensi.status_presensi == StatusPresensiEnum.belum, + ).first() + + if existing_belum: + # Update record yang sudah ada (pre-populated) + existing_belum.status_presensi = status + existing_belum.waktu_presensi = now + existing_belum.latitude_presensi = request.latitude + existing_belum.longitude_presensi = request.longitude + existing_belum.confidence_score = float(similarity) + existing_belum.lbph_score = float(lbph_sim) + existing_belum.cnn_score = float(cnn_sim) + existing_belum.metode_deteksi = "face_recognition" + db.commit() + db.refresh(existing_belum) + new_presensi = existing_belum + else: + # Insert record baru jika belum ada sama sekali + new_presensi = Presensi( + id_pertemuan=request.id_pertemuan, + id_mahasiswa=mahasiswa.id_mahasiswa, + status_presensi=status, + waktu_presensi=now, + latitude_presensi=request.latitude, + longitude_presensi=request.longitude, + confidence_score=float(similarity), + lbph_score=float(lbph_sim), + cnn_score=float(cnn_sim), + metode_deteksi="face_recognition", + ) + db.add(new_presensi) + db.commit() + db.refresh(new_presensi) + + return FaceVerifyResponse( + success=True, + message="Presensi berhasil dicatat", + nama_mahasiswa=nama, + nim=nim, + status_presensi=status.value, + confidence=float(similarity), + cnn_score=float(cnn_sim), + lbph_score=float(lbph_sim), + is_known=True, + waktu_presensi=now.isoformat(), + inference_time_ms=inference_ms, + ) + else: + return FaceVerifyResponse( + success=False, + message=msg or "Wajah tidak dikenali", + confidence=float(similarity), + cnn_score=float(cnn_sim), + lbph_score=float(lbph_sim), + is_known=False, + inference_time_ms=inference_ms, + ) + + +@router.get("/test-model") +def test_model(): + """Test endpoint to verify model is loaded.""" + wrapper = get_enhanced_face_wrapper() + loaded = wrapper._is_loaded or wrapper.load() + health = wrapper.health() + + return { + "loaded": loaded, + "model": "Enhanced LBPH + MobileFaceNet Score-Level Fusion", + "gallery_size": health.get("gallery_size", 0), + "similarity_threshold": health.get("threshold", 0.0), + "architecture": health.get("architecture", "Score-Level Fusion") + } + + +@router.post("/anti-spoof-test") +def anti_spoof_test( + request: FaceScanRequest, + current_user: User = Depends(get_current_user) +): + """ + Test anti-spoofing tanpa melakukan face recognition. + Berguna untuk debug: kirim foto dan lihat apakah terdeteksi REAL atau FAKE. + + Returns: + is_real : True jika wajah asli + label : 'REAL', 'FAKE', 'NO_FACE', atau 'ERROR' + score : Probabilitas wajah asli (0-1) + message : Deskripsi hasil + raw_scores: Skor detail per model + """ + try: + raw_foto = request.foto + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + try: + foto_wajah = raw_foto.split(",", 1)[1] if "," in raw_foto else raw_foto + image_data = base64.b64decode(foto_wajah) + nparr = np.frombuffer(image_data, np.uint8) + bgr_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if bgr_image is None: + raise HTTPException(status_code=400, detail="Invalid image data") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Image decode failed: {str(e)}") + + anti_spoof_mgr = get_anti_spoof_manager() + if not anti_spoof_mgr._is_loaded: + anti_spoof_mgr.load() + + result = anti_spoof_mgr.predict(bgr_image) + + return { + "success" : True, + "is_real" : result.get("is_real"), + "label" : result.get("label"), + "score" : result.get("score"), + "message" : result.get("message"), + "raw_scores": result.get("raw_scores", {}), + "anti_spoof_health": anti_spoof_mgr.health() + } + + +@router.post("/reload-gallery") +def reload_gallery(): + """ + Reload gallery dari disk tanpa restart server. + Diperlukan setelah enrollment baru via enroll_model.py agar backend + menggunakan gallery terbaru (multi-template LBPH). + """ + from app.utils.dependencies import require_dosen + wrapper = get_enhanced_face_wrapper() + if not wrapper._is_loaded: + wrapper.load() + return {"success": True, "message": "System loaded", "gallery_size": wrapper.get_gallery_size()} + + # Reload gallery dari file .pkl + try: + gallery = wrapper._system.gallery + gallery.load() # load dari disk + size = wrapper.get_gallery_size() + log.info(f"[reload-gallery] Gallery reloaded: {size} identities") + return { + "success": True, + "message": f"Gallery reloaded: {size} identities", + "gallery_size": size, + "identities": wrapper.list_identities() + } + except Exception as e: + log.error(f"[reload-gallery] Error: {e}") + return {"success": False, "message": str(e)} diff --git a/facerecognition_presensi/backend/app/routes/geolokasi.py b/facerecognition_presensi/backend/app/routes/geolokasi.py new file mode 100644 index 0000000..f6b8728 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/geolokasi.py @@ -0,0 +1,50 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends +from app.config.database import get_db +from app.controllers.geolokasi_controller import ( + get_all_geolokasi, + get_geolokasi_by_id, + create_geolokasi, + update_geolokasi, + delete_geolokasi, +) +from app.schemas.geolokasi import GeolokasiCreate, GeolokasiUpdate, GeolokasiResponse +from app.utils.dependencies import get_current_user, require_admin, require_dosen + +router = APIRouter(prefix="/geolokasi", tags=["Geolokasi"]) + + +@router.get("/", response_model=List[GeolokasiResponse], summary="Daftar semua lokasi presensi") +def list_geolokasi( + status_filter: Optional[str] = None, + skip: int = 0, + limit: int = 100, + db=Depends(get_db), + _=Depends(get_current_user), +): + return get_all_geolokasi(db, status_filter, skip, limit) + + +@router.post("/", response_model=GeolokasiResponse, status_code=201, summary="Tambah lokasi presensi baru") +def add_geolokasi(payload: GeolokasiCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_geolokasi(db, payload) + + +@router.get("/{geolokasi_id}", response_model=GeolokasiResponse, summary="Detail lokasi presensi") +def detail_geolokasi(geolokasi_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_geolokasi_by_id(db, geolokasi_id) + + +@router.put("/{geolokasi_id}", response_model=GeolokasiResponse, summary="Update lokasi presensi") +def edit_geolokasi( + geolokasi_id: int, + payload: GeolokasiUpdate, + db=Depends(get_db), + _=Depends(require_admin), +): + return update_geolokasi(db, geolokasi_id, payload) + + +@router.delete("/{geolokasi_id}", status_code=204, summary="Hapus lokasi presensi") +def hapus_geolokasi(geolokasi_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_geolokasi(db, geolokasi_id) diff --git a/facerecognition_presensi/backend/app/routes/history_presensi.py b/facerecognition_presensi/backend/app/routes/history_presensi.py new file mode 100644 index 0000000..55f18a9 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/history_presensi.py @@ -0,0 +1,317 @@ +""" +Route: /history-presensi +Menyediakan endpoint untuk mengambil data riwayat presensi dari tabel history_presensi. +Data di-enrich dengan informasi pertemuan, jadwal, matakuliah, kelas, dan mahasiswa. +""" +from fastapi import APIRouter, Depends, HTTPException +from typing import Optional +from app.config.database import get_db +from app.models.history_presensi import HistoryPresensi +from app.models.pertemuan import Pertemuan +from app.models.jadwal import Jadwal +from app.models.penawaran_matakuliah import PenawaranMatakuliah, PenawaranKelas +from app.models.matakuliah import MataKuliah +from app.models.mahasiswa import Mahasiswa +from app.models.prodi import Prodi +from app.models.angkatan import Angkatan +from app.models.golongan import Golongan +from app.utils.dependencies import get_current_user, require_dosen +from sqlalchemy.orm import Session +from sqlalchemy import func + +router = APIRouter(prefix="/history-presensi", tags=["History Presensi"]) + + +def _build_kelas_name(db: Session, penawaran_id: int) -> str: + """Build nama kelas dari penawaran_kelas: Prodi-Angkatan-Golongan""" + pk_list = ( + db.query(PenawaranKelas, Prodi, Angkatan, Golongan) + .join(Prodi, PenawaranKelas.id_prodi == Prodi.id_prodi) + .join(Angkatan, PenawaranKelas.id_angkatan == Angkatan.id_angkatan) + .join(Golongan, PenawaranKelas.id_golongan == Golongan.id_golongan) + .filter(PenawaranKelas.id_penawaran == penawaran_id) + .all() + ) + names = [] + for pk, prodi, angkatan, golongan in pk_list: + names.append(f"{prodi.kode_prodi}-{angkatan.tahun_angkatan}-{golongan.nama_golongan}") + return ", ".join(names) if names else "-" + + +@router.get("/summary", summary="Ringkasan riwayat per pertemuan") +def history_summary( + id_jadwal: Optional[int] = None, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + """ + Ringkasan riwayat presensi yang di-group per pertemuan. + Mengembalikan statistik hadir/terlambat/izin/sakit/alpha per pertemuan. + Digunakan oleh frontend untuk section Riwayat Presensi. + """ + q = db.query(HistoryPresensi) + + if id_jadwal: + pertemuan_ids = [ + p.id_pertemuan + for p in db.query(Pertemuan.id_pertemuan) + .filter(Pertemuan.id_jadwal == id_jadwal) + .all() + ] + if not pertemuan_ids: + return [] + q = q.filter(HistoryPresensi.id_pertemuan.in_(pertemuan_ids)) + + records = q.all() + + if not records: + return [] + + # Group by pertemuan + stats = {} + for r in records: + pid = r.id_pertemuan + if pid not in stats: + stats[pid] = {"total": 0, "hadir": 0, "terlambat": 0, "izin": 0, "sakit": 0, "alpha": 0} + stats[pid]["total"] += 1 + status_val = r.status_presensi.value if r.status_presensi else "alpha" + if status_val in stats[pid]: + stats[pid][status_val] += 1 + + # Enrich with pertemuan + jadwal info + pertemuan_ids_all = list(stats.keys()) + pertemuan_map = {} + jadwal_map = {} + penawaran_map = {} + mk_map = {} + kelas_name_cache = {} + + if pertemuan_ids_all: + pertemuan_list = ( + db.query(Pertemuan) + .filter(Pertemuan.id_pertemuan.in_(pertemuan_ids_all)) + .all() + ) + for p in pertemuan_list: + pertemuan_map[p.id_pertemuan] = p + + jadwal_ids = list({p.id_jadwal for p in pertemuan_list}) + jadwal_list = db.query(Jadwal).filter(Jadwal.id_jadwal.in_(jadwal_ids)).all() + for j in jadwal_list: + jadwal_map[j.id_jadwal] = j + + penawaran_ids = list({j.id_penawaran for j in jadwal_list}) + if penawaran_ids: + penawaran_list = ( + db.query(PenawaranMatakuliah) + .filter(PenawaranMatakuliah.id_penawaran.in_(penawaran_ids)) + .all() + ) + for pen in penawaran_list: + penawaran_map[pen.id_penawaran] = pen + + mk_ids = list({pen.id_matakuliah for pen in penawaran_list}) + mk_list = db.query(MataKuliah).filter(MataKuliah.id_matakuliah.in_(mk_ids)).all() + for mk in mk_list: + mk_map[mk.id_matakuliah] = mk + + # Build kelas names + for pid in penawaran_ids: + kelas_name_cache[pid] = _build_kelas_name(db, pid) + + result = [] + for pid, s in stats.items(): + prt = pertemuan_map.get(pid) + jdw = jadwal_map.get(prt.id_jadwal) if prt else None + pen = penawaran_map.get(jdw.id_penawaran) if jdw else None + mk = mk_map.get(pen.id_matakuliah) if pen else None + kelas_name = kelas_name_cache.get(pen.id_penawaran, "-") if pen else "-" + + result.append({ + "id_pertemuan": pid, + "id_jadwal": prt.id_jadwal if prt else None, + "pertemuan_ke": prt.pertemuan_ke if prt else None, + "tanggal": str(prt.tanggal) if prt else None, + "nama_mk": mk.nama_matakuliah if mk else "-", + "kode_mk": mk.kode_matakuliah if mk else "-", + "nama_kelas": kelas_name, + "hari": jdw.hari.value if jdw else "-", + "jam_mulai": str(jdw.jam_mulai) if jdw else "-", + "jam_selesai": str(jdw.jam_selesai) if jdw else "-", + "ruangan": jdw.ruangan if jdw else "-", + "total": s["total"], + "hadir": s["hadir"] + s["terlambat"], + "terlambat": s["terlambat"], + "izin": s["izin"], + "sakit": s["sakit"], + "alpha": s["alpha"], + "mode_lokasi": prt.mode_lokasi.value if prt and prt.mode_lokasi else "bebas", + }) + + # Sort by tanggal descending + result.sort(key=lambda x: x.get("tanggal") or "", reverse=True) + + return result + + +@router.get("/", summary="Get semua riwayat presensi (detail per mahasiswa)") +def list_history_presensi( + id_pertemuan: Optional[int] = None, + id_mahasiswa: Optional[int] = None, + id_jadwal: Optional[int] = None, + status_filter: Optional[str] = None, + skip: int = 0, + limit: int = 500, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + """ + Ambil data detail dari tabel history_presensi, enriched dengan info + pertemuan, jadwal, matakuliah, dan mahasiswa. + """ + q = db.query(HistoryPresensi) + + if id_pertemuan: + q = q.filter(HistoryPresensi.id_pertemuan == id_pertemuan) + if id_mahasiswa: + q = q.filter(HistoryPresensi.id_mahasiswa == id_mahasiswa) + if status_filter: + q = q.filter(HistoryPresensi.status_presensi == status_filter) + + # Filter by jadwal (via pertemuan) + if id_jadwal: + pertemuan_ids = [ + p.id_pertemuan + for p in db.query(Pertemuan.id_pertemuan) + .filter(Pertemuan.id_jadwal == id_jadwal) + .all() + ] + if not pertemuan_ids: + return [] + q = q.filter(HistoryPresensi.id_pertemuan.in_(pertemuan_ids)) + + records = q.order_by(HistoryPresensi.created_at.desc()).offset(skip).limit(limit).all() + + if not records: + return [] + + # Build enrichment maps + pertemuan_ids_set = list({r.id_pertemuan for r in records}) + pertemuan_map = {} + jadwal_map = {} + + pertemuan_list = ( + db.query(Pertemuan) + .filter(Pertemuan.id_pertemuan.in_(pertemuan_ids_set)) + .all() + ) + for p in pertemuan_list: + pertemuan_map[p.id_pertemuan] = p + + jadwal_ids = list({p.id_jadwal for p in pertemuan_list}) + jadwal_list = db.query(Jadwal).filter(Jadwal.id_jadwal.in_(jadwal_ids)).all() + for j in jadwal_list: + jadwal_map[j.id_jadwal] = j + + penawaran_ids = list({j.id_penawaran for j in jadwal_list}) + penawaran_map = {} + mk_map = {} + kelas_name_cache = {} + + if penawaran_ids: + penawaran_list = ( + db.query(PenawaranMatakuliah) + .filter(PenawaranMatakuliah.id_penawaran.in_(penawaran_ids)) + .all() + ) + for pen in penawaran_list: + penawaran_map[pen.id_penawaran] = pen + + mk_ids = list({pen.id_matakuliah for pen in penawaran_list}) + mk_list = db.query(MataKuliah).filter(MataKuliah.id_matakuliah.in_(mk_ids)).all() + for mk in mk_list: + mk_map[mk.id_matakuliah] = mk + + for pid in penawaran_ids: + kelas_name_cache[pid] = _build_kelas_name(db, pid) + + # Build mahasiswa map + mhs_ids = list({r.id_mahasiswa for r in records}) + mhs_map = {} + if mhs_ids: + mhs_list = db.query(Mahasiswa).filter(Mahasiswa.id_mahasiswa.in_(mhs_ids)).all() + for m in mhs_list: + mhs_map[m.id_mahasiswa] = m + + # Build result + result = [] + for r in records: + prt = pertemuan_map.get(r.id_pertemuan) + jdw = jadwal_map.get(prt.id_jadwal) if prt else None + pen = penawaran_map.get(jdw.id_penawaran) if jdw else None + mk = mk_map.get(pen.id_matakuliah) if pen else None + mhs = mhs_map.get(r.id_mahasiswa) + kelas_name = kelas_name_cache.get(pen.id_penawaran, "-") if pen else "-" + + result.append({ + "id_history": r.id_history, + "id_mahasiswa": r.id_mahasiswa, + "nim": mhs.nim if mhs else "-", + "nama_mahasiswa": mhs.nama_mahasiswa if mhs else "-", + "id_pertemuan": r.id_pertemuan, + "pertemuan_ke": prt.pertemuan_ke if prt else None, + "tanggal": str(prt.tanggal) if prt else None, + "id_jadwal": prt.id_jadwal if prt else None, + "nama_mk": mk.nama_matakuliah if mk else "-", + "kode_mk": mk.kode_matakuliah if mk else "-", + "nama_kelas": kelas_name, + "hari": jdw.hari.value if jdw else "-", + "jam_mulai": str(jdw.jam_mulai) if jdw else "-", + "jam_selesai": str(jdw.jam_selesai) if jdw else "-", + "ruangan": jdw.ruangan if jdw else "-", + "waktu_presensi": r.waktu_presensi.isoformat() if r.waktu_presensi else None, + "status_presensi": r.status_presensi.value if r.status_presensi else None, + "confidence_score": r.confidence_score, + "lbph_score": r.lbph_score, + "cnn_score": r.cnn_score, + "latitude_presensi": float(r.latitude_presensi) if r.latitude_presensi else None, + "longitude_presensi": float(r.longitude_presensi) if r.longitude_presensi else None, + "jarak_meter": r.jarak_meter, + "created_at": r.created_at.isoformat() if r.created_at else None, + }) + + return result + + +@router.delete("/pertemuan/{id_pertemuan}", status_code=204, summary="Hapus riwayat presensi per pertemuan") +def delete_history_pertemuan( + id_pertemuan: int, + db: Session = Depends(get_db), + _=Depends(require_dosen), +): + """Hapus riwayat presensi & pertemuan berdasarkan id_pertemuan.""" + from app.controllers.jadwal_controller import delete_pertemuan + + db.query(HistoryPresensi).filter(HistoryPresensi.id_pertemuan == id_pertemuan).delete( + synchronize_session="fetch" + ) + db.commit() + + try: + delete_pertemuan(db, id_pertemuan) + except Exception: + pass + + +@router.delete("/{id_history}", status_code=204, summary="Hapus single record history presensi") +def delete_single_history( + id_history: int, + db: Session = Depends(get_db), + _=Depends(require_dosen), +): + h = db.query(HistoryPresensi).filter(HistoryPresensi.id_history == id_history).first() + if not h: + raise HTTPException(404, "History presensi tidak ditemukan") + db.delete(h) + db.commit() + diff --git a/facerecognition_presensi/backend/app/routes/jadwal.py b/facerecognition_presensi/backend/app/routes/jadwal.py new file mode 100644 index 0000000..508d934 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/jadwal.py @@ -0,0 +1,617 @@ +from typing import List + +from app.config.database import get_db +from app.controllers.jadwal_controller import ( + archive_pertemuan_by_date, + batalkan_pertemuan, + create_jadwal, + create_jadwal_from_matakuliah, + create_pertemuan, + create_pertemuan_bebas, + delete_jadwal, + delete_pertemuan, + get_all_jadwal, + get_all_pertemuan, + get_jadwal_by_id, + get_pertemuan_by_id, + get_presensi_per_pertemuan, + mulai_pertemuan, + selesai_pertemuan, + update_jadwal, + update_pertemuan, +) +from app.schemas.jadwal import ( + JadwalCreate, + JadwalFromMatakuliahCreate, + JadwalUpdate, + PertemuanBebasCreate, + PertemuanBebasResponse, + PertemuanCreate, + PertemuanResponse, + PertemuanStatusToggle, + PertemuanUpdate, +) +from app.utils.dependencies import get_current_user, require_admin, require_dosen +from fastapi import APIRouter, Depends + +# ─── JADWAL ─────────────────────────────────────────────────────────────────── +router = APIRouter(prefix="/jadwal", tags=["Jadwal"]) + + +@router.get("/") +def list_jadwal( + id_penawaran: int | None = None, + hari: str | None = None, + status_filter: str | None = None, + skip: int = 0, + limit: int = 100, + db=Depends(get_db), + _=Depends(get_current_user), +): + return get_all_jadwal(db, id_penawaran, hari, status_filter, skip, limit) + + +@router.post("/", status_code=201) +def add_jadwal(payload: JadwalCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_jadwal(db, payload) + + +@router.post("/from-matakuliah", status_code=201) +def add_jadwal_from_matakuliah( + payload: JadwalFromMatakuliahCreate, db=Depends(get_db), _=Depends(require_admin) +): + """Buat jadwal baru dari mata kuliah — otomatis create penawaran jika belum ada.""" + return create_jadwal_from_matakuliah(db, payload) + + +@router.get("/{jadwal_id}") +def detail_jadwal(jadwal_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_jadwal_by_id(db, jadwal_id) + + +@router.put("/{jadwal_id}") +def edit_jadwal( + jadwal_id: int, payload: JadwalUpdate, db=Depends(get_db), _=Depends(require_admin) +): + return update_jadwal(db, jadwal_id, payload) + + +@router.delete("/{jadwal_id}", status_code=204) +def hapus_jadwal(jadwal_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_jadwal(db, jadwal_id) + + +# ─── PERTEMUAN ──────────────────────────────────────────────────────────────── +pertemuan_router = APIRouter(prefix="/pertemuan", tags=["Pertemuan"]) + + +@pertemuan_router.get("/", response_model=List[PertemuanResponse]) +def list_pertemuan( + id_jadwal: int | None = None, + status_filter: str | None = None, + skip: int = 0, + limit: int = 100, + db=Depends(get_db), + _=Depends(get_current_user), +): + return get_all_pertemuan(db, id_jadwal, status_filter, skip, limit) + + +@pertemuan_router.post("/", response_model=PertemuanResponse, status_code=201) +def add_pertemuan( + payload: PertemuanCreate, db=Depends(get_db), _=Depends(require_dosen) +): + return create_pertemuan(db, payload) + + +@pertemuan_router.post("/bebas", status_code=201) +def add_pertemuan_bebas( + payload: PertemuanBebasCreate, + db=Depends(get_db), + current_user=Depends(require_dosen), +): + return create_pertemuan_bebas(db, payload, current_user=current_user) + + +# ── PENTING: /me harus didaftarkan SEBELUM /{pertemuan_id} apapun ──────────── +@pertemuan_router.get( + "/me", summary="Pertemuan aktif/tidak aktif untuk mahasiswa yang sedang login" +) +def pertemuan_saya( + is_aktif: bool = True, + skip: int = 0, + limit: int = 100, + db=Depends(get_db), + current_user=Depends(get_current_user), +): + """ + Kembalikan pertemuan yang relevan dengan kelas mahasiswa yang login. + + Query params: + - is_aktif=True (default): pertemuan dengan status_pertemuan BELUM atau SEDANG (untuk mobile) + - is_aktif=False: pertemuan dengan status_pertemuan SELESAI atau DIBATALKAN (riwayat/web) + + Strategi (multi-fallback): + 1. Cocokkan via PenawaranKelas (id_prodi + id_angkatan + id_golongan) + 2. Fallback: cocokkan via id_kelas -> angkatan & golongan dari tabel Kelas + 3. Admin/dosen: tampilkan semua pertemuan + """ + import logging + log = logging.getLogger(__name__) + + log.info(f"[/pertemuan/me] Called by user: role={current_user.role}, email={current_user.email}, is_aktif={is_aktif}") + + from app.models.jadwal import Jadwal + from app.models.kelas import Kelas + from app.models.mahasiswa import Mahasiswa + from app.models.penawaran_matakuliah import PenawaranKelas + from app.models.user import UserRoleEnum + + # ── Admin / Dosen: tampilkan semua pertemuan, tetap filter by is_aktif ── + if current_user.role != UserRoleEnum.mahasiswa: + log.info(f"[/pertemuan/me] User is not mahasiswa, returning meetings (is_aktif={is_aktif})") + all_result = get_all_pertemuan( + db, status_filter=None, skip=skip, limit=limit + ) + + from datetime import datetime as dt_cls, timezone as tz_cls + + def _is_expired(p: dict) -> bool: + try: + batas = p.get("batas_presensi") + if not batas: + return False + now = dt_cls.now(tz_cls.utc) + if isinstance(batas, dt_cls): + batas_dt = batas + else: + batas_dt = dt_cls.fromisoformat(str(batas).replace("Z", "+00:00")) + if batas_dt.tzinfo is None: + batas_dt = batas_dt.replace(tzinfo=tz_cls.utc) + return now > batas_dt + except Exception: + return False + + if is_aktif: + all_result = [ + r for r in all_result + if r["status_pertemuan"] in ("belum", "sedang") and not _is_expired(r) + ] + else: + all_result = [ + r for r in all_result + if r["status_pertemuan"] in ("selesai", "dibatalkan") or _is_expired(r) + ] + + return all_result + + # ── Cari mahasiswa dari email user login ────────────────────────────────── + mhs = db.query(Mahasiswa).filter(Mahasiswa.email == current_user.email).first() + if not mhs: + log.warning(f"[/pertemuan/me] Mahasiswa tidak ditemukan: email={current_user.email}") + return [] + + log.info( + f"[/pertemuan/me] mhs={mhs.nim} | prodi={mhs.id_prodi} " + f"angkatan={mhs.id_angkatan} golongan={mhs.id_golongan} kelas={mhs.id_kelas}" + ) + + jadwal_ids: list[int] = [] + + # ── Strategi 1: PenawaranKelas (id_prodi + id_angkatan + id_golongan) ───────────── + pen_ids = [ + pk.id_penawaran + for pk in db.query(PenawaranKelas) + .filter( + PenawaranKelas.id_prodi == mhs.id_prodi, + PenawaranKelas.id_angkatan == mhs.id_angkatan, + PenawaranKelas.id_golongan == mhs.id_golongan, + ) + .all() + ] + if pen_ids: + jadwal_ids = [ + j.id_jadwal + for j in db.query(Jadwal).filter(Jadwal.id_penawaran.in_(pen_ids)).all() + ] + log.info(f"[/pertemuan/me] S1 OK -> penawaran={pen_ids} jadwal={jadwal_ids}") + else: + log.warning(f"[/pertemuan/me] S1 FAILED: No PenawaranKelas found for prodi={mhs.id_prodi}, angkatan={mhs.id_angkatan}, golongan={mhs.id_golongan}") + + # ── Strategi 2: Fallback via id_kelas ──────────────────────────────────── + if not jadwal_ids and mhs.id_kelas: + kelas = db.query(Kelas).filter(Kelas.id == mhs.id_kelas).first() + if kelas: + log.info(f"[/pertemuan/me] S2 via kelas={kelas.nama_kelas}") + from app.models.angkatan import Angkatan + from app.models.golongan import Golongan + + ang = ( + db.query(Angkatan) + .filter(Angkatan.tahun_angkatan == kelas.angkatan) + .first() + ) if kelas.angkatan else None + gol = ( + db.query(Golongan) + .filter(Golongan.nama_golongan == kelas.golongan) + .first() + ) if kelas.golongan else None + + q2 = db.query(PenawaranKelas).filter( + PenawaranKelas.id_prodi == kelas.id_prodi + ) + if ang: + q2 = q2.filter(PenawaranKelas.id_angkatan == ang.id_angkatan) + if gol: + q2 = q2.filter(PenawaranKelas.id_golongan == gol.id_golongan) + + pen_ids_2 = [pk.id_penawaran for pk in q2.all()] + if pen_ids_2: + jadwal_ids = [ + j.id_jadwal + for j in db.query(Jadwal) + .filter(Jadwal.id_penawaran.in_(pen_ids_2)) + .all() + ] + log.info(f"[/pertemuan/me] S2 OK -> penawaran={pen_ids_2} jadwal={jadwal_ids}") + else: + log.warning(f"[/pertemuan/me] S2 FAILED: No PenawaranKelas found via kelas fallback") + else: + log.warning(f"[/pertemuan/me] S2 FAILED: Kelas with id={mhs.id_kelas} not found") + + if not jadwal_ids: + log.warning( + f"[/pertemuan/me] Tidak ada jadwal untuk {mhs.nim}. " + "Pastikan mahasiswa memiliki kelas dengan PenawaranKelas yang cocok." + ) + return [] + + # ── Ambil data pertemuan dari jadwal_ids ───────────────────────────────── + result = get_all_pertemuan(db, jadwal_ids=jadwal_ids, skip=skip, limit=limit) + + # Debug: log raw result + log.info(f"[/pertemuan/me] Raw result count: {len(result)}") + for r in result[:3]: # Log first 3 + log.info(f" -> id={r['id_pertemuan']}, status={r['status_pertemuan']}, tanggal={r['tanggal']}") + + # ── Helper: apakah presensi sudah melewati tenggat waktu? ──────────────── + from datetime import datetime, timezone + + def is_presensi_expired(p: dict) -> bool: + """Return True jika presensi sudah melewati batas_presensi.""" + try: + batas = p.get("batas_presensi") + if not batas: + return False + import zoneinfo + wib = zoneinfo.ZoneInfo("Asia/Jakarta") + now = datetime.now(wib).replace(tzinfo=None) + # Handle datetime object langsung (dari SQLAlchemy) + if isinstance(batas, datetime): + batas_dt = batas + else: + # Handle string format + batas_dt = datetime.fromisoformat(str(batas).replace("Z", "+00:00")) + # Pastikan naive + if batas_dt.tzinfo is not None: + batas_dt = batas_dt.replace(tzinfo=None) + return now > batas_dt + except Exception as e: + log.warning(f"[is_presensi_expired] Error checking batas: {e}") + return False + + # ── Filter berdasarkan is_aktif + tenggat waktu ────────────────────────── + if is_aktif: + # Mobile: tampilkan yang aktif DAN belum melewati tenggat + result = [ + r for r in result + if r["status_pertemuan"] in ("belum", "sedang") and not is_presensi_expired(r) + ] + else: + # Web riwayat: tampilkan yang selesai/dibatalkan ATAU sudah lewat tenggat + result = [ + r for r in result + if r["status_pertemuan"] in ("selesai", "dibatalkan") or is_presensi_expired(r) + ] + + log.info(f"[/pertemuan/me] Final result: {len(result)} pertemuan untuk {mhs.nim} (is_aktif={is_aktif})") + return result + + +@pertemuan_router.get( + "/riwayat", summary="Pertemuan tidak aktif (riwayat) untuk mahasiswa yang sedang login" +) +def pertemuan_riwayat( + skip: int = 0, + limit: int = 100, + db=Depends(get_db), + current_user=Depends(get_current_user), +): + """ + Kembalikan pertemuan TIDAK AKTIF (status_pertemuan = selesai/dibatalkan) + untuk ditampilkan di section Riwayat Presensi pada web dashboard. + """ + import logging + log = logging.getLogger(__name__) + + from app.models.jadwal import Jadwal + from app.models.kelas import Kelas + from app.models.mahasiswa import Mahasiswa + from app.models.penawaran_matakuliah import PenawaranKelas, PenawaranMatakuliah + from app.models.pertemuan import Pertemuan, StatusPertemuanEnum + from app.models.presensi import Presensi + from app.models.matakuliah import MataKuliah + from app.models.user import UserRoleEnum + + inactive_statuses = [StatusPertemuanEnum.selesai, StatusPertemuanEnum.dibatalkan] + + # ── Admin / Dosen: tampilkan semua pertemuan tidak aktif ────────────────── + if current_user.role != UserRoleEnum.mahasiswa: + all_inactive = ( + db.query(Pertemuan) + .filter(Pertemuan.status_pertemuan.in_(inactive_statuses)) + .order_by(Pertemuan.tanggal.desc()) + .offset(skip) + .limit(limit) + .all() + ) + result = [] + for p in all_inactive: + jadwal = db.query(Jadwal).filter(Jadwal.id_jadwal == p.id_jadwal).first() + penawaran = ( + db.query(PenawaranMatakuliah) + .filter(PenawaranMatakuliah.id_penawaran == jadwal.id_penawaran) + .first() if jadwal else None + ) + mk = ( + db.query(MataKuliah) + .filter(MataKuliah.id_matakuliah == penawaran.id_matakuliah) + .first() if penawaran else None + ) + presensi_list = db.query(Presensi).filter(Presensi.id_pertemuan == p.id_pertemuan).all() + total = len(presensi_list) + sudah_hadir = sum(1 for pr in presensi_list if pr.status_presensi.value in ("hadir", "terlambat")) + belum = sum(1 for pr in presensi_list if pr.status_presensi.value == "belum") + total_alpa = sum(1 for pr in presensi_list if pr.status_presensi.value in ("alpha", "izin", "sakit")) + + nama_kelas = None + if penawaran: + pk_list = db.query(PenawaranKelas).filter(PenawaranKelas.id_penawaran == penawaran.id_penawaran).all() + if pk_list: + pk = pk_list[0] + parts = [] + if pk.prodi: parts.append(pk.prodi.kode_prodi) + if pk.angkatan: parts.append(str(pk.angkatan.tahun_angkatan)) + if pk.golongan: parts.append(pk.golongan.nama_golongan) + nama_kelas = "-".join(parts) if parts else None + + result.append({ + "id_pertemuan": p.id_pertemuan, + "id_jadwal": p.id_jadwal, + "pertemuan_ke": p.pertemuan_ke, + "tanggal": p.tanggal, + "batas_presensi": p.batas_presensi, + "status_pertemuan": p.status_pertemuan.value, + "id_geolokasi": p.id_geolokasi, + "mode_lokasi": p.mode_lokasi.value if p.mode_lokasi else "bebas", + "nama_matakuliah": mk.nama_matakuliah if mk else None, + "kode_matakuliah": mk.kode_matakuliah if mk else None, + "nama_kelas": nama_kelas, + "total_mahasiswa": total, + "sudah_hadir": sudah_hadir, + "belum_presensi": belum, + "total_hadir": sudah_hadir, + "total_alpa": total_alpa, + }) + return result + + # ── Cari mahasiswa dari email user login ────────────────────────────────── + mhs = db.query(Mahasiswa).filter(Mahasiswa.email == current_user.email).first() + if not mhs: + log.warning(f"[/pertemuan/riwayat] Mahasiswa tidak ditemukan: email={current_user.email}") + return [] + + jadwal_ids: list[int] = [] + + # ── Strategi 1: PenawaranKelas ──────────────────────────────────────────── + pen_ids = [ + pk.id_penawaran + for pk in db.query(PenawaranKelas) + .filter( + PenawaranKelas.id_prodi == mhs.id_prodi, + PenawaranKelas.id_angkatan == mhs.id_angkatan, + PenawaranKelas.id_golongan == mhs.id_golongan, + ) + .all() + ] + if pen_ids: + jadwal_ids = [ + j.id_jadwal + for j in db.query(Jadwal).filter(Jadwal.id_penawaran.in_(pen_ids)).all() + ] + + # ── Strategi 2: Fallback via id_kelas ─────────────────────────────────── + if not jadwal_ids and mhs.id_kelas: + kelas = db.query(Kelas).filter(Kelas.id == mhs.id_kelas).first() + if kelas: + from app.models.angkatan import Angkatan + from app.models.golongan import Golongan + + ang = ( + db.query(Angkatan) + .filter(Angkatan.tahun_angkatan == kelas.angkatan) + .first() + ) if kelas.angkatan else None + gol = ( + db.query(Golongan) + .filter(Golongan.nama_golongan == kelas.golongan) + .first() + ) if kelas.golongan else None + + q2 = db.query(PenawaranKelas).filter( + PenawaranKelas.id_prodi == kelas.id_prodi + ) + if ang: + q2 = q2.filter(PenawaranKelas.id_angkatan == ang.id_angkatan) + if gol: + q2 = q2.filter(PenawaranKelas.id_golongan == gol.id_golongan) + + pen_ids_2 = [pk.id_penawaran for pk in q2.all()] + if pen_ids_2: + jadwal_ids = [ + j.id_jadwal + for j in db.query(Jadwal) + .filter(Jadwal.id_penawaran.in_(pen_ids_2)) + .all() + ] + + if not jadwal_ids: + return [] + + # ── Helper: apakah batas_presensi sudah lewat? ─────────────────────────── + from datetime import datetime, timezone + + def is_expired(p: Pertemuan) -> bool: + """Return True jika batas_presensi sudah lewat.""" + try: + if not p.batas_presensi: + return False + batas_dt = p.batas_presensi + if batas_dt.tzinfo is not None: + batas_dt = batas_dt.replace(tzinfo=None) + import zoneinfo + wib = zoneinfo.ZoneInfo("Asia/Jakarta") + now = datetime.now(wib).replace(tzinfo=None) + return now > batas_dt + except Exception: + return False + + # Query semua pertemuan terkait jadwal, termasuk yang statusnya masih aktif tapi sudah lewat tenggat + all_pertemuan = ( + db.query(Pertemuan) + .filter(Pertemuan.id_jadwal.in_(jadwal_ids)) + .order_by(Pertemuan.tanggal.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + # Filter: tampilkan jika status sudah selesai/dibatalkan ATAU sudah lewat batas_presensi + inactive_pertemuan = [ + p for p in all_pertemuan + if p.status_pertemuan in inactive_statuses or is_expired(p) + ] + + result = [] + for p in inactive_pertemuan: + jadwal = db.query(Jadwal).filter(Jadwal.id_jadwal == p.id_jadwal).first() + penawaran = ( + db.query(PenawaranMatakuliah) + .filter(PenawaranMatakuliah.id_penawaran == jadwal.id_penawaran) + .first() if jadwal else None + ) + mk = ( + db.query(MataKuliah) + .filter(MataKuliah.id_matakuliah == penawaran.id_matakuliah) + .first() if penawaran else None + ) + presensi_list = db.query(Presensi).filter(Presensi.id_pertemuan == p.id_pertemuan).all() + total = len(presensi_list) + sudah_hadir = sum(1 for pr in presensi_list if pr.status_presensi.value in ("hadir", "terlambat")) + belum = sum(1 for pr in presensi_list if pr.status_presensi.value == "belum") + total_alpa = sum(1 for pr in presensi_list if pr.status_presensi.value in ("alpha", "izin", "sakit")) + + nama_kelas = None + if penawaran: + pk_list = db.query(PenawaranKelas).filter(PenawaranKelas.id_penawaran == penawaran.id_penawaran).all() + if pk_list: + pk = pk_list[0] + parts = [] + if pk.prodi: parts.append(pk.prodi.kode_prodi) + if pk.angkatan: parts.append(str(pk.angkatan.tahun_angkatan)) + if pk.golongan: parts.append(pk.golongan.nama_golongan) + nama_kelas = "-".join(parts) if parts else None + + result.append({ + "id_pertemuan": p.id_pertemuan, + "id_jadwal": p.id_jadwal, + "pertemuan_ke": p.pertemuan_ke, + "tanggal": p.tanggal, + "batas_presensi": p.batas_presensi, + "status_pertemuan": p.status_pertemuan.value, + "id_geolokasi": p.id_geolokasi, + "mode_lokasi": p.mode_lokasi.value if p.mode_lokasi else "bebas", + "nama_matakuliah": mk.nama_matakuliah if mk else None, + "kode_matakuliah": mk.kode_matakuliah if mk else None, + "nama_kelas": nama_kelas, + "total_mahasiswa": total, + "sudah_hadir": sudah_hadir, + "belum_presensi": belum, + "total_hadir": sudah_hadir, + "total_alpa": total_alpa, + }) + + log.info(f"[/pertemuan/riwayat] {len(result)} pertemuan tidak aktif") + return result + + +# ── Routes dengan path parameter ───────────────────────────────────────────── +@pertemuan_router.get( + "/{pertemuan_id}/presensi", + summary="Daftar mahasiswa & status presensi per pertemuan", +) +def presensi_per_pertemuan( + pertemuan_id: int, db=Depends(get_db), _=Depends(get_current_user) +): + return get_presensi_per_pertemuan(db, pertemuan_id) + + +@pertemuan_router.get("/{pertemuan_id}", response_model=PertemuanResponse) +def detail_pertemuan( + pertemuan_id: int, db=Depends(get_db), _=Depends(get_current_user) +): + return get_pertemuan_by_id(db, pertemuan_id) + + +@pertemuan_router.put("/{pertemuan_id}", response_model=PertemuanResponse) +def edit_pertemuan( + pertemuan_id: int, + payload: PertemuanUpdate, + db=Depends(get_db), + _=Depends(require_dosen), +): + return update_pertemuan(db, pertemuan_id, payload) + + +@pertemuan_router.post("/{pertemuan_id}/mulai", response_model=PertemuanResponse) +def mulai( + pertemuan_id: int, + durasi_menit: int = 15, + db=Depends(get_db), + _=Depends(require_dosen), +): + return mulai_pertemuan(db, pertemuan_id, durasi_menit) + + +@pertemuan_router.post("/{pertemuan_id}/selesai", response_model=PertemuanResponse) +def selesai(pertemuan_id: int, db=Depends(get_db), _=Depends(require_dosen)): + return selesai_pertemuan(db, pertemuan_id) + + +@pertemuan_router.post("/archive-by-date", summary="Archive presensi yang sudah lewat tanggal") +def archive_old_presensi(db=Depends(get_db), _=Depends(require_dosen)): + """ + Archive semua presensi yang tanggalnya sudah lewat ke history_presensi. + Digunakan untuk auto-archive presensi yang tidak ditandai selesai secara manual. + """ + return archive_pertemuan_by_date(db) + + +@pertemuan_router.post("/{pertemuan_id}/batalkan", response_model=PertemuanResponse) +def batalkan(pertemuan_id: int, db=Depends(get_db), _=Depends(require_dosen)): + return batalkan_pertemuan(db, pertemuan_id) + + +@pertemuan_router.delete("/{pertemuan_id}", status_code=204) +def hapus_pertemuan(pertemuan_id: int, db=Depends(get_db), _=Depends(require_dosen)): + delete_pertemuan(db, pertemuan_id) diff --git a/facerecognition_presensi/backend/app/routes/kelas.py b/facerecognition_presensi/backend/app/routes/kelas.py new file mode 100644 index 0000000..da420d1 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/kelas.py @@ -0,0 +1,47 @@ +from typing import List + +from app.config.database import get_db +from app.controllers.kelas_controller import ( + create_kelas, + delete_kelas, + get_all_kelas, + get_kelas_by_id, + update_kelas, +) +from app.schemas.kelas import KelasCreate, KelasResponse, KelasUpdate +from app.utils.dependencies import get_current_user, require_admin +from fastapi import APIRouter, Depends + +router = APIRouter(prefix="/kelas", tags=["Kelas"]) + + +@router.get("/", response_model=List[KelasResponse]) +def list_kelas( + id_prodi: int | None = None, + angkatan: int | None = None, + db=Depends(get_db), + _=Depends(get_current_user), +): + return get_all_kelas(db, id_prodi, angkatan) + + +@router.post("/", response_model=KelasResponse, status_code=201) +def add_kelas(payload: KelasCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_kelas(db, payload) + + +@router.get("/{kelas_id}", response_model=KelasResponse) +def detail_kelas(kelas_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_kelas_by_id(db, kelas_id) + + +@router.put("/{kelas_id}", response_model=KelasResponse) +def edit_kelas( + kelas_id: int, payload: KelasUpdate, db=Depends(get_db), _=Depends(require_admin) +): + return update_kelas(db, kelas_id, payload) + + +@router.delete("/{kelas_id}", status_code=204) +def hapus_kelas(kelas_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_kelas(db, kelas_id) diff --git a/facerecognition_presensi/backend/app/routes/mahasiswa.py b/facerecognition_presensi/backend/app/routes/mahasiswa.py new file mode 100644 index 0000000..c53becd --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/mahasiswa.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from typing import List +import os +from app.config.database import get_db +from app.config.settings import settings +from app.controllers.mahasiswa_controller import ( + get_all_mahasiswa, get_mahasiswa_by_id, get_mahasiswa_by_nim, + create_mahasiswa, update_mahasiswa, delete_mahasiswa, bulk_create_mahasiswa, bulk_delete_mahasiswa +) +from app.schemas.dosen import MahasiswaCreate, MahasiswaUpdate, MahasiswaResponse +from app.utils.dependencies import get_current_user, require_admin + +router = APIRouter(prefix="/mahasiswa", tags=["Mahasiswa"]) + +@router.get("/nim/{nim}/foto", response_class=FileResponse) +def get_foto_mahasiswa(nim: str): + """ + Mengambil foto profile mahasiswa berdasarkan NIM. + Akan mencari foto pertama (foto_001.jpg) di folder face_mahasiswa/{nim}. + """ + foto_dir = settings.FACE_MAHASISWA_DIR / nim + + foto_path = foto_dir / "foto_001.jpg" + + if foto_path.exists(): + return FileResponse(str(foto_path)) + + # Jika foto_001.jpg tidak ada, cari foto .jpg apa saja + if foto_dir.exists(): + for f in os.listdir(foto_dir): + if f.endswith(".jpg"): + return FileResponse(str(foto_dir / f)) + + raise HTTPException(status_code=404, detail="Foto tidak ditemukan") + + +@router.get("/", response_model=List[MahasiswaResponse]) +def list_mahasiswa( + id_prodi: int | None = None, id_angkatan: int | None = None, + id_golongan: int | None = None, status_filter: str | None = None, + skip: int = 0, limit: int = 100, + db=Depends(get_db), _=Depends(get_current_user), +): + return get_all_mahasiswa(db, id_prodi, id_angkatan, id_golongan, status_filter, skip, limit) + + +@router.post("/", response_model=MahasiswaResponse, status_code=201) +def add_mahasiswa(payload: MahasiswaCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_mahasiswa(db, payload) + + +@router.post("/bulk", status_code=201) +def add_mahasiswa_bulk(payloads: List[MahasiswaCreate], db=Depends(get_db), _=Depends(require_admin)): + return bulk_create_mahasiswa(db, payloads) + +@router.post("/bulk-delete", status_code=200) +def hapus_mahasiswa_bulk(payload: List[int], db=Depends(get_db), _=Depends(require_admin)): + return bulk_delete_mahasiswa(db, payload) + + +@router.get("/nim/{nim}", response_model=MahasiswaResponse) +def cari_mahasiswa_nim(nim: str, db=Depends(get_db), _=Depends(get_current_user)): + return get_mahasiswa_by_nim(db, nim) + + +@router.get("/{mahasiswa_id}", response_model=MahasiswaResponse) +def detail_mahasiswa(mahasiswa_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_mahasiswa_by_id(db, mahasiswa_id) + + +@router.put("/{mahasiswa_id}", response_model=MahasiswaResponse) +def edit_mahasiswa(mahasiswa_id: int, payload: MahasiswaUpdate, db=Depends(get_db), _=Depends(require_admin)): + return update_mahasiswa(db, mahasiswa_id, payload) + + +@router.delete("/{mahasiswa_id}", status_code=204) +def hapus_mahasiswa(mahasiswa_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_mahasiswa(db, mahasiswa_id) diff --git a/facerecognition_presensi/backend/app/routes/penawaran.py b/facerecognition_presensi/backend/app/routes/penawaran.py new file mode 100644 index 0000000..57e0627 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/penawaran.py @@ -0,0 +1,123 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import List +from app.config.database import get_db +from app.controllers.penawaran_controller import ( + get_all_penawaran, get_penawaran_by_id, create_penawaran, update_penawaran, delete_penawaran, + get_kelas_by_penawaran, add_kelas_to_penawaran, add_kelas_to_penawaran_by_kelas_id, remove_kelas_from_penawaran, + get_pengajar_by_penawaran, add_pengajar_to_penawaran, update_pengajar_role, remove_pengajar, + get_pengajar_pertemuan, add_pengajar_pertemuan, remove_pengajar_pertemuan, +) +from app.schemas.penawaran import ( + PenawaranCreate, PenawaranUpdate, + PenawaranKelasCreate, PenawaranKelasUpdate, + PengajarCreate, PengajarUpdate, + PengajarPertemuanCreate, +) +from app.utils.dependencies import get_current_user, require_admin, require_dosen + +# ─── PENAWARAN MATA KULIAH ──────────────────────────────────────────────────── +penawaran_router = APIRouter(prefix="/penawaran-matakuliah", tags=["Penawaran Mata Kuliah"]) + + +@penawaran_router.get("/") +def list_penawaran( + id_matakuliah: int | None = None, + status_filter: str | None = None, + skip: int = 0, limit: int = 100, + db=Depends(get_db), _=Depends(get_current_user), +): + return get_all_penawaran(db, id_matakuliah, status_filter, skip, limit) + + +@penawaran_router.post("/", status_code=201) +def create_penawaran_mk(payload: PenawaranCreate, db=Depends(get_db), _=Depends(require_admin)): + return create_penawaran(db, payload) + + +@penawaran_router.get("/{id_penawaran}") +def detail_penawaran(id_penawaran: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_penawaran_by_id(db, id_penawaran) + + +@penawaran_router.put("/{id_penawaran}") +def edit_penawaran(id_penawaran: int, payload: PenawaranUpdate, db=Depends(get_db), _=Depends(require_admin)): + return update_penawaran(db, id_penawaran, payload) + + +@penawaran_router.delete("/{id_penawaran}", status_code=204) +def hapus_penawaran(id_penawaran: int, db=Depends(get_db), _=Depends(require_admin)): + delete_penawaran(db, id_penawaran) + + +# ─── PENAWARAN KELAS ────────────────────────────────────────────────────────── +penawaran_kelas_router = APIRouter(prefix="/penawaran-kelas", tags=["Penawaran Kelas"]) + + +@penawaran_kelas_router.get("/penawaran/{id_penawaran}") +def list_kelas(id_penawaran: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_kelas_by_penawaran(db, id_penawaran) + + +@penawaran_kelas_router.post("/", status_code=201) +def add_kelas(payload: PenawaranKelasCreate, db=Depends(get_db), _=Depends(require_admin)): + return add_kelas_to_penawaran(db, payload) + + +class KelasFromKelasPayload(BaseModel): + id_penawaran: int + id_kelas: int + + +@penawaran_kelas_router.post("/from-kelas", status_code=201) +def add_kelas_from_kelas_id(payload: KelasFromKelasPayload, db=Depends(get_db), _=Depends(require_admin)): + """Tambah kelas ke penawaran menggunakan id_kelas dari tabel kelas langsung.""" + return add_kelas_to_penawaran_by_kelas_id(db, payload.id_penawaran, payload.id_kelas) + + +@penawaran_kelas_router.delete("/{kelas_id}", status_code=204) +def hapus_kelas(kelas_id: int, db=Depends(get_db), _=Depends(require_admin)): + remove_kelas_from_penawaran(db, kelas_id) + + +# ─── PENGAJAR ───────────────────────────────────────────────────────────────── +pengajar_router = APIRouter(prefix="/pengajar", tags=["Pengajar"]) + + +@pengajar_router.get("/penawaran/{id_penawaran}") +def list_pengajar(id_penawaran: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_pengajar_by_penawaran(db, id_penawaran) + + +@pengajar_router.post("/", status_code=201) +def add_pengajar(payload: PengajarCreate, db=Depends(get_db), _=Depends(require_admin)): + return add_pengajar_to_penawaran(db, payload) + + +@pengajar_router.put("/{pengajar_id}") +def edit_pengajar_role(pengajar_id: int, payload: PengajarUpdate, db=Depends(get_db), _=Depends(require_admin)): + return update_pengajar_role(db, pengajar_id, payload) + + +@pengajar_router.delete("/{pengajar_id}", status_code=204) +def hapus_pengajar(pengajar_id: int, db=Depends(get_db), _=Depends(require_admin)): + remove_pengajar(db, pengajar_id) + + +# ─── PENGAJAR PERTEMUAN ─────────────────────────────────────────────────────── +pengajar_pertemuan_router = APIRouter(prefix="/pengajar-pertemuan", tags=["Pengajar Pertemuan"]) + + +@pengajar_pertemuan_router.get("/pertemuan/{id_pertemuan}") +def list_pengajar_pertemuan(id_pertemuan: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_pengajar_pertemuan(db, id_pertemuan) + + +@pengajar_pertemuan_router.post("/", status_code=201) +def add_pengajar_in_pertemuan(payload: PengajarPertemuanCreate, db=Depends(get_db), _=Depends(require_dosen)): + return add_pengajar_pertemuan(db, payload) + + +@pengajar_pertemuan_router.delete("/{pp_id}", status_code=204) +def hapus_pengajar_pertemuan(pp_id: int, db=Depends(get_db), _=Depends(require_admin)): + remove_pengajar_pertemuan(db, pp_id) diff --git a/facerecognition_presensi/backend/app/routes/presensi.py b/facerecognition_presensi/backend/app/routes/presensi.py new file mode 100644 index 0000000..80a5332 --- /dev/null +++ b/facerecognition_presensi/backend/app/routes/presensi.py @@ -0,0 +1,199 @@ +from fastapi import APIRouter, Depends, UploadFile, File +from typing import List +from app.config.database import get_db +from app.models.dataset_wajah import DatasetWajah, JenisFotoEnum +from app.models.mahasiswa import Mahasiswa +from app.models.registrasi_wajah import RegistrasiWajah +from app.models.log_deteksi import LogDeteksi +from app.controllers.presensi_controller import ( + get_all_presensi, get_presensi_by_id, create_presensi_manual, + absensi_face_recognition, update_presensi, delete_presensi, + rekap_per_pertemuan, rekap_mahasiswa_per_jadwal, + create_log_deteksi, get_all_log, get_log_by_id, update_log_deteksi, delete_log_deteksi, + create_registrasi, update_registrasi, get_registrasi_by_id, delete_registrasi, + get_dataset_by_id, update_dataset_wajah, +) +from app.schemas.presensi import ( + PresensiCreate, PresensiUpdate, PresensiResponse, + FaceRecognitionAbsenRequest, RekapPresensiResponse, + DatasetWajahUpdate, DatasetWajahResponse, + RegistrasiWajahCreate, RegistrasiWajahUpdate, RegistrasiWajahResponse, + LogDeteksiCreate, LogDeteksiUpdate, LogDeteksiResponse, +) +from app.utils.dependencies import get_current_user, require_admin, require_dosen +from app.utils.helpers import save_dataset_file, delete_file_if_exists + +# ─── PRESENSI ───────────────────────────────────────────────────────────────── +router = APIRouter(prefix="/presensi", tags=["Presensi"]) + + +@router.get("/", response_model=List[PresensiResponse]) +def list_presensi(id_pertemuan: int | None = None, id_mahasiswa: int | None = None, status_filter: str | None = None, skip: int = 0, limit: int = 100, db=Depends(get_db), _=Depends(get_current_user)): + return get_all_presensi(db, id_pertemuan, id_mahasiswa, status_filter, skip, limit) + + +@router.post("/", response_model=PresensiResponse, status_code=201, summary="Buat presensi manual") +def add_presensi(payload: PresensiCreate, db=Depends(get_db), _=Depends(require_dosen)): + return create_presensi_manual(db, payload) + + +@router.post("/face-recognition", response_model=PresensiResponse, summary="Absensi via face recognition") +def absen_face(payload: FaceRecognitionAbsenRequest, db=Depends(get_db), _=Depends(get_current_user)): + return absensi_face_recognition(db, payload) + + +@router.get("/rekap/pertemuan/{pertemuan_id}", summary="Rekap presensi per pertemuan") +def rekap_pertemuan(pertemuan_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return rekap_per_pertemuan(db, pertemuan_id) + + +@router.get("/rekap/mahasiswa/{mahasiswa_id}/jadwal/{jadwal_id}", response_model=RekapPresensiResponse) +def rekap_mahasiswa(mahasiswa_id: int, jadwal_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return rekap_mahasiswa_per_jadwal(db, mahasiswa_id, jadwal_id) + + +@router.get("/{presensi_id}", response_model=PresensiResponse) +def detail_presensi(presensi_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_presensi_by_id(db, presensi_id) + + +@router.put("/{presensi_id}", response_model=PresensiResponse) +def edit_presensi(presensi_id: int, payload: PresensiUpdate, db=Depends(get_db), _=Depends(require_dosen)): + return update_presensi(db, presensi_id, payload) + + +@router.delete("/{presensi_id}", status_code=204) +def hapus_presensi(presensi_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_presensi(db, presensi_id) + + +# ─── DATASET WAJAH ──────────────────────────────────────────────────────────── +dataset_router = APIRouter(prefix="/dataset-wajah", tags=["Dataset Wajah"]) + + +@dataset_router.get("/", response_model=List[DatasetWajahResponse]) +def list_dataset(id_mahasiswa: int | None = None, jenis_foto: str | None = None, db=Depends(get_db), _=Depends(get_current_user)): + q = db.query(DatasetWajah) + if id_mahasiswa: + q = q.filter(DatasetWajah.id_mahasiswa == id_mahasiswa) + if jenis_foto: + q = q.filter(DatasetWajah.jenis_foto == jenis_foto) + return q.order_by(DatasetWajah.created_at.desc()).all() + + +@dataset_router.post("/upload/{mahasiswa_id}", response_model=DatasetWajahResponse, status_code=201) +def upload_foto(mahasiswa_id: int, file: UploadFile = File(...), jenis_foto: JenisFotoEnum = JenisFotoEnum.registrasi, db=Depends(get_db), _=Depends(require_dosen)): + from fastapi import HTTPException + mhs = db.query(Mahasiswa).filter( + Mahasiswa.id_mahasiswa == mahasiswa_id).first() + if not mhs: + raise HTTPException(404, "Mahasiswa tidak ditemukan") + count = db.query(DatasetWajah).filter( + DatasetWajah.id_mahasiswa == mahasiswa_id).count() + path = save_dataset_file(file, mahasiswa_id, mhs.nim, count + 1) + d = DatasetWajah(id_mahasiswa=mahasiswa_id, + foto_path=path, jenis_foto=jenis_foto) + db.add(d) + db.commit() + db.refresh(d) + return d + + +@dataset_router.get("/mahasiswa/{mahasiswa_id}/count") +def count_dataset(mahasiswa_id: int, db=Depends(get_db), _=Depends(get_current_user)): + total = db.query(DatasetWajah).filter( + DatasetWajah.id_mahasiswa == mahasiswa_id).count() + return {"id_mahasiswa": mahasiswa_id, "total_foto": total, "siap_registrasi": total >= 3} + + +@dataset_router.get("/{dataset_id}", response_model=DatasetWajahResponse) +def detail_dataset(dataset_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_dataset_by_id(db, dataset_id) + + +@dataset_router.put("/{dataset_id}", response_model=DatasetWajahResponse) +def edit_dataset(dataset_id: int, payload: DatasetWajahUpdate, db=Depends(get_db), _=Depends(require_dosen)): + return update_dataset_wajah(db, dataset_id, payload) + + +@dataset_router.delete("/{dataset_id}", status_code=204) +def hapus_dataset(dataset_id: int, db=Depends(get_db), _=Depends(require_admin)): + from fastapi import HTTPException + d = db.query(DatasetWajah).filter( + DatasetWajah.id_dataset == dataset_id).first() + if not d: + raise HTTPException(404, "Dataset tidak ditemukan") + delete_file_if_exists(d.foto_path) + db.delete(d) + db.commit() + + +# ─── REGISTRASI WAJAH ───────────────────────────────────────────────────────── +registrasi_router = APIRouter( + prefix="/registrasi-wajah", tags=["Registrasi Wajah"]) + + +@registrasi_router.get("/", response_model=List[RegistrasiWajahResponse]) +def list_registrasi(id_mahasiswa: int | None = None, db=Depends(get_db), _=Depends(get_current_user)): + q = db.query(RegistrasiWajah) + if id_mahasiswa: + q = q.filter(RegistrasiWajah.id_mahasiswa == id_mahasiswa) + return q.order_by(RegistrasiWajah.created_at.desc()).all() + + +@registrasi_router.post("/", response_model=RegistrasiWajahResponse, status_code=201) +def add_registrasi(payload: RegistrasiWajahCreate, db=Depends(get_db), _=Depends(require_dosen)): + return create_registrasi(db, payload) + + +@registrasi_router.get("/{reg_id}", response_model=RegistrasiWajahResponse) +def detail_registrasi(reg_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_registrasi_by_id(db, reg_id) + + +@registrasi_router.put("/{reg_id}", response_model=RegistrasiWajahResponse) +def edit_registrasi(reg_id: int, payload: RegistrasiWajahUpdate, db=Depends(get_db), _=Depends(require_dosen)): + return update_registrasi(db, reg_id, payload) + + +@registrasi_router.delete("/{reg_id}", status_code=204) +def hapus_registrasi(reg_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_registrasi(db, reg_id) + + +# ─── LOG DETEKSI ────────────────────────────────────────────────────────────── +log_router = APIRouter(prefix="/log-deteksi", tags=["Log Deteksi"]) + + +@log_router.get("/", response_model=List[LogDeteksiResponse]) +def list_log(id_mahasiswa: int | None = None, hasil_filter: str | None = None, skip: int = 0, limit: int = 100, db=Depends(get_db), _=Depends(get_current_user)): + return get_all_log(db, id_mahasiswa, hasil_filter, skip, limit) + + +@log_router.get("/statistik") +def statistik_log(db=Depends(get_db), _=Depends(get_current_user)): + from sqlalchemy import func + stats = db.query(LogDeteksi.hasil_deteksi, func.count( + LogDeteksi.id_log)).group_by(LogDeteksi.hasil_deteksi).all() + total = sum(c for _, c in stats) + return {"total_deteksi": total, "ringkasan": {h.value: c for h, c in stats}} + + +@log_router.post("/", response_model=LogDeteksiResponse, status_code=201) +def add_log(payload: LogDeteksiCreate, db=Depends(get_db), _=Depends(get_current_user)): + return create_log_deteksi(db, payload) + + +@log_router.get("/{log_id}", response_model=LogDeteksiResponse) +def detail_log(log_id: int, db=Depends(get_db), _=Depends(get_current_user)): + return get_log_by_id(db, log_id) + + +@log_router.put("/{log_id}", response_model=LogDeteksiResponse) +def edit_log(log_id: int, payload: LogDeteksiUpdate, db=Depends(get_db), _=Depends(require_dosen)): + return update_log_deteksi(db, log_id, payload) + + +@log_router.delete("/{log_id}", status_code=204) +def hapus_log(log_id: int, db=Depends(get_db), _=Depends(require_admin)): + delete_log_deteksi(db, log_id) diff --git a/facerecognition_presensi/backend/app/routes/registrasi.py b/facerecognition_presensi/backend/app/routes/registrasi.py new file mode 100644 index 0000000..e69de29 diff --git a/facerecognition_presensi/backend/app/schemas/dosen.py b/facerecognition_presensi/backend/app/schemas/dosen.py new file mode 100644 index 0000000..dcef0dc --- /dev/null +++ b/facerecognition_presensi/backend/app/schemas/dosen.py @@ -0,0 +1,81 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from enum import Enum +from datetime import datetime + + +# ── Dosen Schemas ──────────────────────────────────────────────────────────── + +class StatusDosenEnum(str, Enum): + aktif = "aktif" + nonaktif = "nonaktif" + + +class DosenCreate(BaseModel): + nidn: str + nama_dosen: str + email: Optional[EmailStr] = None + no_hp: Optional[str] = None + status: StatusDosenEnum = StatusDosenEnum.aktif + + +class DosenUpdate(BaseModel): + nidn: Optional[str] = None + nama_dosen: Optional[str] = None + email: Optional[EmailStr] = None + no_hp: Optional[str] = None + status: Optional[StatusDosenEnum] = None + + +class DosenResponse(DosenCreate): + id_dosen: int + model_config = {"from_attributes": True} + + +# ── Mahasiswa Schemas ──────────────────────────────────────────────────────── + +class JenisKelaminEnum(str, Enum): + L = "L" + P = "P" + + +class StatusMahasiswaEnum(str, Enum): + aktif = "aktif" + nonaktif = "nonaktif" + lulus = "lulus" + cuti = "cuti" + + +class MahasiswaCreate(BaseModel): + nim: str + nama_mahasiswa: str + jenis_kelamin: Optional[JenisKelaminEnum] = None + email: Optional[EmailStr] = None + no_hp: Optional[str] = None + id_prodi: int + id_angkatan: int + id_golongan: int + id_kelas: Optional[int] = None + foto_profil: Optional[str] = None + status: StatusMahasiswaEnum = StatusMahasiswaEnum.aktif + + +class MahasiswaUpdate(BaseModel): + nim: Optional[str] = None + nama_mahasiswa: Optional[str] = None + jenis_kelamin: Optional[JenisKelaminEnum] = None + email: Optional[EmailStr] = None + no_hp: Optional[str] = None + id_prodi: Optional[int] = None + id_angkatan: Optional[int] = None + id_golongan: Optional[int] = None + id_kelas: Optional[int] = None + foto_profil: Optional[str] = None + status: Optional[StatusMahasiswaEnum] = None + + +class MahasiswaResponse(MahasiswaCreate): + id_mahasiswa: int + created_at: datetime + updated_at: datetime + model_config = {"from_attributes": True} diff --git a/facerecognition_presensi/backend/app/schemas/geolokasi.py b/facerecognition_presensi/backend/app/schemas/geolokasi.py new file mode 100644 index 0000000..9886258 --- /dev/null +++ b/facerecognition_presensi/backend/app/schemas/geolokasi.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel, Field +from typing import Optional +from enum import Enum +from datetime import datetime + + +class StatusGeolokasiEnum(str, Enum): + aktif = "aktif" + nonaktif = "nonaktif" + + +class GeolokasiCreate(BaseModel): + nama_lokasi: str = Field(..., max_length=100, description="Nama lokasi presensi") + deskripsi: Optional[str] = None + latitude: float = Field(..., ge=-90.0, le=90.0) + longitude: float = Field(..., ge=-180.0, le=180.0) + radius_meter: int = Field(default=100, ge=10, le=10000, description="Radius validasi dalam meter") + status: StatusGeolokasiEnum = StatusGeolokasiEnum.aktif + + +class GeolokasiUpdate(BaseModel): + nama_lokasi: Optional[str] = Field(None, max_length=100) + deskripsi: Optional[str] = None + latitude: Optional[float] = Field(None, ge=-90.0, le=90.0) + longitude: Optional[float] = Field(None, ge=-180.0, le=180.0) + radius_meter: Optional[int] = Field(None, ge=10, le=10000) + status: Optional[StatusGeolokasiEnum] = None + + +class GeolokasiResponse(BaseModel): + id_geolokasi: int + nama_lokasi: str + deskripsi: Optional[str] = None + latitude: float + longitude: float + radius_meter: int + status: StatusGeolokasiEnum + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/facerecognition_presensi/backend/app/schemas/jadwal.py b/facerecognition_presensi/backend/app/schemas/jadwal.py new file mode 100644 index 0000000..489977a --- /dev/null +++ b/facerecognition_presensi/backend/app/schemas/jadwal.py @@ -0,0 +1,140 @@ +from datetime import date, datetime, time +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +# ── Jadwal Schemas ─────────────────────────────────────────────────────────── + + +class HariEnum(str, Enum): + senin = "Senin" + selasa = "Selasa" + rabu = "Rabu" + kamis = "Kamis" + jumat = "Jumat" + sabtu = "Sabtu" + minggu = "Minggu" + + +class StatusJadwalEnum(str, Enum): + aktif = "aktif" + nonaktif = "nonaktif" + + +class JadwalCreate(BaseModel): + id_penawaran: int + hari: HariEnum + jam_mulai: time + jam_selesai: time + ruangan: Optional[str] = None + status: StatusJadwalEnum = StatusJadwalEnum.aktif + + +class JadwalFromMatakuliahCreate(BaseModel): + """Buat jadwal langsung dari mata kuliah — auto-create penawaran jika belum ada.""" + + id_matakuliah: int + semester: int + tahun_ajaran: str # e.g. "2024/2025" + hari: HariEnum + jam_mulai: time + jam_selesai: time + ruangan: Optional[str] = None + id_kelas: Optional[int] = None # Jika diisi, langsung tambahkan kelas ke penawaran + + +class JadwalUpdate(BaseModel): + id_penawaran: Optional[int] = None + hari: Optional[HariEnum] = None + jam_mulai: Optional[time] = None + jam_selesai: Optional[time] = None + ruangan: Optional[str] = None + status: Optional[StatusJadwalEnum] = None + + +class JadwalResponse(JadwalCreate): + id_jadwal: int + # Derived/joined fields from penawaran + id_matakuliah: Optional[int] = None + kode_matakuliah: Optional[str] = None + nama_matakuliah: Optional[str] = None + semester: Optional[int] = None + tahun_ajaran: Optional[str] = None + model_config = {"from_attributes": True} + + +# ── Pertemuan Schemas ──────────────────────────────────────────────────────── + + +class StatusPertemuanEnum(str, Enum): + belum = "belum" + sedang = "sedang" + selesai = "selesai" + dibatalkan = "dibatalkan" + + +class PertemuanCreate(BaseModel): + id_jadwal: int + pertemuan_ke: int + tanggal: date + batas_presensi: Optional[datetime] = None + status_pertemuan: StatusPertemuanEnum = StatusPertemuanEnum.belum + id_geolokasi: Optional[int] = None + mode_lokasi: str = "bebas" # "bebas" | "wajib" + + +class PertemuanUpdate(BaseModel): + pertemuan_ke: Optional[int] = None + tanggal: Optional[date] = None + batas_presensi: Optional[datetime] = None + status_pertemuan: Optional[StatusPertemuanEnum] = None + id_geolokasi: Optional[int] = None + mode_lokasi: Optional[str] = None + + +class PertemuanResponse(PertemuanCreate): + id_pertemuan: int + id_geolokasi: Optional[int] = None + mode_lokasi: str = "bebas" + # Derived fields — populated by list endpoint + nama_matakuliah: Optional[str] = None + kode_matakuliah: Optional[str] = None + nama_kelas: Optional[str] = None # e.g. "TI-2022-A" + total_mahasiswa: Optional[int] = None + sudah_hadir: Optional[int] = None + belum_presensi: Optional[int] = None + total_hadir: Optional[int] = None # alias for sudah_hadir + total_alpa: Optional[int] = None # students marked as alpha/izin/sakit + model_config = {"from_attributes": True} + + +class PertemuanStatusToggle(BaseModel): + status_pertemuan: StatusPertemuanEnum + + +class PertemuanBebasCreate(BaseModel): + """Buat sesi presensi langsung dari matakuliah + kelas, tanpa perlu jadwal yang sudah ada.""" + + id_matakuliah: int + id_kelas: int # FK ke tabel kelas (bukan penawaran_kelas) + pertemuan_ke: int + tanggal: date + tahun_ajaran: str # e.g. "2024/2025" + semester: int = 1 + jam_mulai: Optional[time] = None + jam_selesai: Optional[time] = None + id_geolokasi: Optional[int] = None + mode_lokasi: str = "bebas" # "bebas" | "wajib" + + +class PertemuanBebasResponse(BaseModel): + id_pertemuan: int + id_jadwal: int + pertemuan_ke: int + tanggal: str + status_pertemuan: str + nama_mk: str + kode_mk: str + nama_kelas: str + total_mahasiswa: int diff --git a/facerecognition_presensi/backend/app/schemas/kelas.py b/facerecognition_presensi/backend/app/schemas/kelas.py new file mode 100644 index 0000000..06ecf16 --- /dev/null +++ b/facerecognition_presensi/backend/app/schemas/kelas.py @@ -0,0 +1,33 @@ +from typing import Optional + +from pydantic import BaseModel + + +class KelasCreate(BaseModel): + id_prodi: int + nama_kelas: str + golongan: Optional[str] = None + angkatan: Optional[int] = None + tahun_ajaran: Optional[str] = None + keterangan: Optional[str] = None + + +class KelasUpdate(BaseModel): + id_prodi: Optional[int] = None + nama_kelas: Optional[str] = None + golongan: Optional[str] = None + angkatan: Optional[int] = None + tahun_ajaran: Optional[str] = None + keterangan: Optional[str] = None + + +class KelasResponse(BaseModel): + id: int + id_prodi: int + nama_kelas: str + golongan: Optional[str] = None + angkatan: Optional[int] = None + tahun_ajaran: Optional[str] = None + keterangan: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/facerecognition_presensi/backend/app/schemas/mahasiswa.py b/facerecognition_presensi/backend/app/schemas/mahasiswa.py new file mode 100644 index 0000000..e69de29 diff --git a/facerecognition_presensi/backend/app/schemas/master.py b/facerecognition_presensi/backend/app/schemas/master.py new file mode 100644 index 0000000..9c32719 --- /dev/null +++ b/facerecognition_presensi/backend/app/schemas/master.py @@ -0,0 +1,112 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from enum import Enum +from datetime import datetime + + +# ── User / Auth Schemas ────────────────────────────────────────────────────── + +class UserRoleEnum(str, Enum): + admin = "admin" + dosen = "dosen" + mahasiswa = "mahasiswa" + + +class UserCreate(BaseModel): + name: str + email: EmailStr + password: str + role: UserRoleEnum = UserRoleEnum.mahasiswa + + +class UserUpdate(BaseModel): + name: Optional[str] = None + email: Optional[EmailStr] = None + role: Optional[UserRoleEnum] = None + + +class UserResponse(BaseModel): + id: int + name: str + email: str + role: UserRoleEnum + model_config = {"from_attributes": True} + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserResponse + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + +# ── Prodi Schemas ──────────────────────────────────────────────────────────── + +class ProdiCreate(BaseModel): + kode_prodi: str + nama_prodi: str + jenjang: Optional[str] = None + + +class ProdiUpdate(BaseModel): + kode_prodi: Optional[str] = None + nama_prodi: Optional[str] = None + jenjang: Optional[str] = None + + +class ProdiResponse(ProdiCreate): + id_prodi: int + model_config = {"from_attributes": True} + + +# ── Angkatan Schemas ───────────────────────────────────────────────────────── + +class AngkatanCreate(BaseModel): + tahun_angkatan: int + + +class AngkatanUpdate(BaseModel): + tahun_angkatan: Optional[int] = None + + +class AngkatanResponse(AngkatanCreate): + id_angkatan: int + model_config = {"from_attributes": True} + + +# ── Golongan Schemas ───────────────────────────────────────────────────────── + +class GolonganCreate(BaseModel): + nama_golongan: str + + +class GolonganUpdate(BaseModel): + nama_golongan: Optional[str] = None + + +class GolonganResponse(GolonganCreate): + id_golongan: int + model_config = {"from_attributes": True} + + +# ── MataKuliah Schemas ─────────────────────────────────────────────────────── + +class MataKuliahCreate(BaseModel): + kode_matakuliah: str + nama_matakuliah: str + sks: int + + +class MataKuliahUpdate(BaseModel): + kode_matakuliah: Optional[str] = None + nama_matakuliah: Optional[str] = None + sks: Optional[int] = None + + +class MataKuliahResponse(MataKuliahCreate): + id_matakuliah: int + model_config = {"from_attributes": True} diff --git a/facerecognition_presensi/backend/app/schemas/penawaran.py b/facerecognition_presensi/backend/app/schemas/penawaran.py new file mode 100644 index 0000000..e4459f1 --- /dev/null +++ b/facerecognition_presensi/backend/app/schemas/penawaran.py @@ -0,0 +1,116 @@ +from pydantic import BaseModel +from typing import Optional, List +from enum import Enum +from datetime import datetime + + +# ── Enums ───────────────────────────────────────────────────────────────────── + +class StatusPenawaranEnum(str, Enum): + aktif = "aktif" + nonaktif = "nonaktif" + + +class RolePengajarEnum(str, Enum): + koordinator = "koordinator" + anggota = "anggota" + + +# ── Penawaran Mata Kuliah ───────────────────────────────────────────────────── + +class PenawaranCreate(BaseModel): + id_matakuliah: int + semester: int + tahun_ajaran: str + status: StatusPenawaranEnum = StatusPenawaranEnum.aktif + + +class PenawaranUpdate(BaseModel): + semester: Optional[int] = None + tahun_ajaran: Optional[str] = None + status: Optional[StatusPenawaranEnum] = None + + +class PenawaranResponse(PenawaranCreate): + id_penawaran: int + created_at: Optional[datetime] = None + # Derived/joined fields + nama_matakuliah: Optional[str] = None + kode_matakuliah: Optional[str] = None + model_config = {"from_attributes": True} + + +# ── Penawaran Kelas ─────────────────────────────────────────────────────────── + +class PenawaranKelasCreate(BaseModel): + id_penawaran: int + id_prodi: int + id_angkatan: int + id_golongan: int + + +class PenawaranKelasUpdate(BaseModel): + id_prodi: Optional[int] = None + id_angkatan: Optional[int] = None + id_golongan: Optional[int] = None + + +class PenawaranKelasResponse(PenawaranKelasCreate): + id: int + # Derived/joined fields + kode_prodi: Optional[str] = None + tahun_angkatan: Optional[int] = None + nama_golongan: Optional[str] = None + nama_kelas: Optional[str] = None + model_config = {"from_attributes": True} + + +# ── Pengajar ────────────────────────────────────────────────────────────────── + +class PengajarCreate(BaseModel): + id_penawaran: int + id_dosen: int + role: RolePengajarEnum = RolePengajarEnum.anggota + + +class PengajarUpdate(BaseModel): + role: Optional[RolePengajarEnum] = None + + +class PengajarResponse(PengajarCreate): + id_pengajar: int + # Derived/joined fields + nama_dosen: Optional[str] = None + nidn: Optional[str] = None + model_config = {"from_attributes": True} + + +# ── Pengajar Pertemuan ──────────────────────────────────────────────────────── + +class PengajarPertemuanCreate(BaseModel): + id_pertemuan: int + id_dosen: int + + +class PengajarPertemuanResponse(PengajarPertemuanCreate): + id: int + # Derived/joined fields + nama_dosen: Optional[str] = None + nidn: Optional[str] = None + model_config = {"from_attributes": True} + + +# ── Composite Response (full detail dengan nested data) ─────────────────────── + +class PenawaranDetailResponse(BaseModel): + id_penawaran: int + id_matakuliah: int + kode_matakuliah: Optional[str] = None + nama_matakuliah: Optional[str] = None + semester: int + tahun_ajaran: str + status: StatusPenawaranEnum + created_at: Optional[datetime] = None + kelas_list: List[PenawaranKelasResponse] = [] + pengajar_list: List[PengajarResponse] = [] + model_config = {"from_attributes": True} diff --git a/facerecognition_presensi/backend/app/schemas/presensi.py b/facerecognition_presensi/backend/app/schemas/presensi.py new file mode 100644 index 0000000..595db24 --- /dev/null +++ b/facerecognition_presensi/backend/app/schemas/presensi.py @@ -0,0 +1,162 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +# ── Presensi Schemas ───────────────────────────────────────────────────────── + + +class StatusPresensiEnum(str, Enum): + belum = "belum" # Belum presensi (initial state) + hadir = "hadir" + terlambat = "terlambat" + izin = "izin" + sakit = "sakit" + alpha = "alpha" + + +class PresensiCreate(BaseModel): + id_mahasiswa: int + id_pertemuan: int + status_presensi: StatusPresensiEnum = StatusPresensiEnum.belum + waktu_presensi: Optional[datetime] = None + foto_verifikasi: Optional[str] = None + confidence_score: Optional[float] = None + lbph_score: Optional[float] = None + cnn_score: Optional[float] = None + metode_deteksi: Optional[str] = None + + +class PresensiUpdate(BaseModel): + status_presensi: Optional[StatusPresensiEnum] = None + waktu_presensi: Optional[datetime] = None + foto_verifikasi: Optional[str] = None + confidence_score: Optional[float] = None + lbph_score: Optional[float] = None + cnn_score: Optional[float] = None + metode_deteksi: Optional[str] = None + + +class PresensiResponse(PresensiCreate): + id_presensi: int + latitude_presensi: Optional[float] = None + longitude_presensi: Optional[float] = None + jarak_meter: Optional[float] = None + model_config = {"from_attributes": True} + + +class FaceRecognitionAbsenRequest(BaseModel): + """Request dari ML model setelah deteksi wajah berhasil.""" + + id_pertemuan: int + id_mahasiswa: int + confidence_score: float + lbph_score: Optional[float] = None + cnn_score: Optional[float] = None + metode_deteksi: Optional[str] = None + foto_verifikasi: Optional[str] = None + # Geolocation (wajib dikirim jika pertemuan mode_lokasi == 'wajib') + latitude: Optional[float] = None + longitude: Optional[float] = None + + +class RekapPresensiResponse(BaseModel): + id_mahasiswa: int + nim: str + nama_mahasiswa: str + total_hadir: int + total_terlambat: int + total_izin: int + total_sakit: int + total_alpha: int + total_pertemuan: int + persentase_kehadiran: float + + +# ── Dataset Wajah Schemas ──────────────────────────────────────────────────── + + +class JenisFotoEnum(str, Enum): + registrasi = "registrasi" + training = "training" + augmentasi = "augmentasi" + + +class DatasetWajahUpdate(BaseModel): + jenis_foto: Optional[JenisFotoEnum] = None + digunakan_training: Optional[int] = None + + +class DatasetWajahResponse(BaseModel): + id_dataset: int + id_mahasiswa: int + foto_path: str + jenis_foto: JenisFotoEnum + digunakan_training: int + created_at: datetime + model_config = {"from_attributes": True} + + +# ── Registrasi Wajah Schemas ───────────────────────────────────────────────── + + +class StatusRegistrasiEnum(str, Enum): + pending = "pending" + berhasil = "berhasil" + gagal = "gagal" + + +class RegistrasiWajahCreate(BaseModel): + id_mahasiswa: int + jumlah_foto: int = 0 + similarity_check: Optional[float] = None + status_registrasi: StatusRegistrasiEnum = StatusRegistrasiEnum.pending + + +class RegistrasiWajahUpdate(BaseModel): + jumlah_foto: Optional[int] = None + similarity_check: Optional[float] = None + status_registrasi: Optional[StatusRegistrasiEnum] = None + + +class RegistrasiWajahResponse(RegistrasiWajahCreate): + id_registrasi: int + created_at: datetime + model_config = {"from_attributes": True} + + +# ── Log Deteksi Schemas ─────────────────────────────────────────────────────── + + +class HasilDeteksiEnum(str, Enum): + match = "match" + unknown = "unknown" + no_face = "no_face" + error = "error" + + +class LogDeteksiCreate(BaseModel): + id_mahasiswa: Optional[int] = None + lbph_score: Optional[float] = None + cnn_score: Optional[float] = None + confidence_score: Optional[float] = None + predicted_label: Optional[str] = None + hasil_deteksi: HasilDeteksiEnum + foto_capture: Optional[str] = None + + +class LogDeteksiUpdate(BaseModel): + id_mahasiswa: Optional[int] = None + lbph_score: Optional[float] = None + cnn_score: Optional[float] = None + confidence_score: Optional[float] = None + predicted_label: Optional[str] = None + hasil_deteksi: Optional[HasilDeteksiEnum] = None + foto_capture: Optional[str] = None + + +class LogDeteksiResponse(LogDeteksiCreate): + id_log: int + waktu_deteksi: datetime + model_config = {"from_attributes": True} diff --git a/facerecognition_presensi/backend/app/services/anti_spoof_service.py b/facerecognition_presensi/backend/app/services/anti_spoof_service.py new file mode 100644 index 0000000..e0b1edc --- /dev/null +++ b/facerecognition_presensi/backend/app/services/anti_spoof_service.py @@ -0,0 +1,323 @@ +""" +anti_spoof_service.py +===================== +Anti-Spoofing Service menggunakan Silent-Face-Anti-Spoofing (minivision-ai). + +Mendeteksi: +- Foto cetak (kertas/print attack) +- Foto dari layar HP/monitor (replay attack) +- Video replay +- Wajah asli (REAL) + +Model: MiniFASNetV2 + MiniFASNetV1SE (ensemble) +File model: SilentFaceAntiSpoofing/resources/anti_spoof_models/*.pth + +Cara kerja: +1. Deteksi wajah menggunakan RetinaFace (bawaan SilentFace) → fallback Haar +2. Crop wajah dengan scale berbeda untuk tiap model +3. Prediksi probabilitas REAL vs FAKE per model +4. Ensemble sum → keputusan final + +Author: Skripsi 2026 +""" + +import logging +import sys +import os +from pathlib import Path +from typing import Dict, Any, Optional + +import numpy as np + +log = logging.getLogger(__name__) + +# ─── Path Configuration ────────────────────────────────────────────────────────── +_CURRENT_FILE = Path(__file__).resolve() +_BACKEND_DIR = _CURRENT_FILE.parent.parent.parent # backend/ +_ROOT_DIR = _BACKEND_DIR.parent # face_recognition_system/ +_SILENT_DIR = _ROOT_DIR / "SilentFaceAntiSpoofing" # SilentFaceAntiSpoofing/ +_MODELS_DIR = _SILENT_DIR / "resources" / "anti_spoof_models" +_DETECT_DIR = _SILENT_DIR / "resources" / "detection_model" + +log.info(f"[AntiSpoof] SilentFace dir : {_SILENT_DIR}") +log.info(f"[AntiSpoof] Models dir : {_MODELS_DIR}") + + +class AntiSpoofManager: + """ + Singleton manager untuk anti-spoofing. + + Penggunaan: + manager = AntiSpoofManager.get_instance() + result = manager.predict(bgr_image) + # result['is_real'] -> True jika wajah asli + # result['score'] -> confidence REAL (0-1) + # result['label'] -> 'REAL', 'FAKE', 'NO_FACE', atau 'ERROR' + # result['message'] -> deskripsi + """ + + _instance: "AntiSpoofManager" = None + _is_loaded: bool = False + _predictor = None # AntiSpoofPredict + _cropper = None # CropImage + + @classmethod + def get_instance(cls) -> "AntiSpoofManager": + if cls._instance is None: + cls._instance = cls() + return cls._instance + + # ─── Loading ──────────────────────────────────────────────────────────────── + + def load(self) -> bool: + """Load SilentFace komponen ke memori.""" + if self._is_loaded: + return True + + if not _SILENT_DIR.exists(): + log.error(f"[AntiSpoof] Folder SilentFace tidak ada: {_SILENT_DIR}") + return False + + model_files = list(_MODELS_DIR.glob("*.pth")) + if not model_files: + log.error(f"[AntiSpoof] Tidak ada model .pth di: {_MODELS_DIR}") + return False + + # Tambahkan SilentFaceAntiSpoofing ke sys.path + silent_str = str(_SILENT_DIR) + if silent_str not in sys.path: + sys.path.insert(0, silent_str) + log.info(f"[AntiSpoof] sys.path += {silent_str}") + + try: + import warnings + warnings.filterwarnings("ignore") + + from src.anti_spoof_predict import AntiSpoofPredict + from src.generate_patches import CropImage + + self._predictor = AntiSpoofPredict(device_id=0) # auto CPU jika tidak ada GPU + self._cropper = CropImage() + self._is_loaded = True + + log.info( + f"[AntiSpoof] Loaded! Models: {[m.name for m in model_files]}" + ) + return True + + except ImportError as e: + log.error(f"[AntiSpoof] Import error: {e}") + log.error( + "[AntiSpoof] Pastikan torch & torchvision terinstall di venv backend.\n" + " pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu" + ) + return False + except Exception as e: + log.error(f"[AntiSpoof] Load error: {e}") + import traceback + traceback.print_exc() + return False + + # ─── Predict ──────────────────────────────────────────────────────────────── + + def predict(self, bgr_image: np.ndarray) -> Dict[str, Any]: + """ + Prediksi apakah wajah REAL atau FAKE (spoof). + + Args: + bgr_image: Gambar BGR (numpy array) + + Returns: + { + is_real (bool | None): True=asli, False=spoof, None=error + score (float) : probabilitas REAL (0-1) + label (str) : 'REAL', 'FAKE', 'NO_FACE', 'ERROR' + message (str) : deskripsi singkat + raw_scores (dict) : skor per model + } + """ + if not self._is_loaded: + if not self.load(): + return self._error_response("Anti-spoof system tidak tersedia") + + try: + import warnings + warnings.filterwarnings("ignore") + from src.utility import parse_model_name + + # 1. Deteksi wajah + image_bbox = self._get_bbox(bgr_image) + if image_bbox is None: + log.warning("[AntiSpoof] Wajah tidak terdeteksi") + return { + "is_real" : False, + "score" : 0.0, + "label" : "NO_FACE", + "message" : "Wajah tidak terdeteksi oleh anti-spoof detector", + "raw_scores": {} + } + + # 2. Jalankan setiap model .pth (ensemble sum) + prediction = np.zeros((1, 3)) + raw_scores: Dict[str, Any] = {} + model_files = list(_MODELS_DIR.glob("*.pth")) + + for model_path in model_files: + model_name = model_path.name + try: + h_input, w_input, model_type, scale = parse_model_name(model_name) + param = { + "org_img": bgr_image, + "bbox" : image_bbox, + "scale" : scale, + "out_w" : w_input, + "out_h" : h_input, + "crop" : scale is not None, + } + img_patch = self._cropper.crop(**param) + pred = self._predictor.predict(img_patch, str(model_path)) + prediction += pred + + label_idx = int(np.argmax(pred)) + raw_scores[model_name] = { + "label" : "REAL" if label_idx == 1 else "FAKE", + "real_prob": float(pred[0][1]), + "fake_prob": float(pred[0][0]), + } + log.debug( + f"[AntiSpoof] {model_name}: " + f"label={raw_scores[model_name]['label']} " + f"real={raw_scores[model_name]['real_prob']:.3f}" + ) + except Exception as model_err: + log.warning(f"[AntiSpoof] Model {model_name} error: {model_err}") + + if not raw_scores: + return self._error_response("Semua model anti-spoof gagal dijalankan") + + # 3. Keputusan final + final_label_idx = int(np.argmax(prediction)) + total = float(prediction.sum()) + 1e-7 + real_score = float(prediction[0][1]) / total # index 1 = REAL + is_real = (final_label_idx == 1) + + log.info( + f"[AntiSpoof] Result: label={'REAL' if is_real else 'FAKE'} " + f"real_score={real_score:.4f} models={list(raw_scores.keys())}" + ) + + if is_real: + return { + "is_real" : True, + "score" : real_score, + "label" : "REAL", + "message" : f"Wajah asli terdeteksi (score: {real_score:.2f})", + "raw_scores": raw_scores + } + else: + return { + "is_real" : False, + "score" : real_score, + "label" : "FAKE", + "message" : ( + f"Terdeteksi spoofing/foto palsu (real_score: {real_score:.2f}). " + "Gunakan wajah asli Anda secara langsung!" + ), + "raw_scores": raw_scores + } + + except Exception as e: + log.error(f"[AntiSpoof] Predict error: {e}") + import traceback + traceback.print_exc() + return self._error_response(f"Anti-spoof internal error: {str(e)}") + + # ─── Helpers ──────────────────────────────────────────────────────────────── + + def _get_bbox(self, bgr_image: np.ndarray) -> Optional[list]: + """ + Deteksi wajah → bounding box [x, y, w, h]. + Primary: RetinaFace bawaan SilentFace. + Fallback: Haar cascade OpenCV. + """ + # Primary: RetinaFace (bawaan SilentFace) + try: + bbox = self._predictor.get_bbox(bgr_image) + if bbox and len(bbox) == 4 and bbox[2] > 0 and bbox[3] > 0: + return bbox + except Exception as e: + log.debug(f"[AntiSpoof] RetinaFace bbox error: {e}") + + # Fallback: Haar cascade + try: + import cv2 + gray = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2GRAY) + haar_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml" + cascade = cv2.CascadeClassifier(haar_path) + faces = cascade.detectMultiScale(gray, 1.1, 4, minSize=(60, 60)) + if len(faces) > 0: + x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) + log.debug(f"[AntiSpoof] Haar fallback bbox: [{x},{y},{w},{h}]") + return [int(x), int(y), int(w), int(h)] + except Exception as e: + log.debug(f"[AntiSpoof] Haar fallback error: {e}") + + return None + + def _error_response(self, message: str) -> Dict[str, Any]: + return { + "is_real" : None, + "score" : 0.0, + "label" : "ERROR", + "message" : message, + "raw_scores": {} + } + + @property + def available(self) -> bool: + return self._is_loaded + + def health(self) -> Dict[str, Any]: + model_files = list(_MODELS_DIR.glob("*.pth")) if _MODELS_DIR.exists() else [] + return { + "available" : self._is_loaded, + "silent_face_dir": str(_SILENT_DIR), + "models_dir" : str(_MODELS_DIR), + "models" : [m.name for m in model_files], + "model_count" : len(model_files), + } + + +# ─── Singleton helpers ─────────────────────────────────────────────────────────── + +_manager_instance: Optional[AntiSpoofManager] = None + + +def get_anti_spoof_manager() -> AntiSpoofManager: + """Singleton AntiSpoofManager.""" + global _manager_instance + if _manager_instance is None: + _manager_instance = AntiSpoofManager() + return _manager_instance + + +def preload_anti_spoof() -> bool: + """Pre-load saat startup backend.""" + try: + return get_anti_spoof_manager().load() + except Exception as e: + log.warning(f"[AntiSpoof] Preload failed: {e}") + return False + + +def check_anti_spoof(bgr_image: np.ndarray) -> Dict[str, Any]: + """ + Convenience function: check apakah gambar spoofing. + + Returns: + Dict dengan is_real, score, label, message + """ + manager = get_anti_spoof_manager() + if not manager._is_loaded: + manager.load() + return manager.predict(bgr_image) diff --git a/facerecognition_presensi/backend/app/services/attendance_service.py b/facerecognition_presensi/backend/app/services/attendance_service.py new file mode 100644 index 0000000..e69de29 diff --git a/facerecognition_presensi/backend/app/services/enhanced_face_service.py b/facerecognition_presensi/backend/app/services/enhanced_face_service.py new file mode 100644 index 0000000..3deb208 --- /dev/null +++ b/facerecognition_presensi/backend/app/services/enhanced_face_service.py @@ -0,0 +1,776 @@ +""" +enhanced_face_service.py +======================== +Service untuk integrasi EnhancedFaceRecognitionSystem (ModelBaru) +ke dalam backend FastAPI. + +ModelBaru Architecture: +- Score-Level Fusion: LBPH + MobileFaceNet (ONNX) +- Quality-Aware Weighting: wCNN = QualityScore, wLBPH = 1 - QualityScore +- FAISS Gallery untuk similarity search +- YuNet face detector + +Fitur: +- Single photo enrollment dengan quality validation +- Augmentation untuk stabilitas (10 versi) +- Incremental template update +- Top-K recognition results +- Quality assessment (Blur, Brightness, Pose) + +Author: Skripsi 2026 +""" + +import logging +import sys +import os +from pathlib import Path +from typing import Dict, Any, List, Optional +from datetime import datetime + +import numpy as np + +log = logging.getLogger(__name__) + +# ─── Path Configuration ───────────────────────────────────────────────────────── + +_CURRENT_FILE = Path(__file__).resolve() +_BACKEND_DIR = _CURRENT_FILE.parent.parent.parent # backend/ +_ROOT_DIR = _BACKEND_DIR.parent # face_recognition_system/ +_MODELBARU_DIR = _ROOT_DIR / "ModelBaru" # ModelBaru/ +_MODELS_DIR = _MODELBARU_DIR / "models" # ModelBaru/models/ +_FACE_MAHASISWA_DIR = _MODELBARU_DIR / "face_mahasiswa" # ModelBaru/face_mahasiswa/ + +# Tambahkan ModelBaru ke sys.path SEBELUM import +if str(_MODELBARU_DIR) not in sys.path: + sys.path.insert(0, str(_MODELBARU_DIR)) + +log.info(f"[EnhancedFace] Backend dir: {_BACKEND_DIR}") +log.info(f"[EnhancedFace] ModelBaru dir: {_MODELBARU_DIR}") +log.info(f"[EnhancedFace] Models dir: {_MODELS_DIR}") + + +# ─── Enhanced Face Recognition Wrapper ───────────────────────────────────────── + +class EnhancedFaceRecognitionWrapper: + """ + Wrapper untuk EnhancedFaceRecognitionSystem dari ModelBaru. + + Arsitektur Score-Level Fusion: + ┌─────────────────────────────────────────────────────────────┐ + │ INPUT IMAGE │ + │ │ │ + │ ▼ │ + │ Face Detection (YuNet) │ + │ │ │ + │ ▼ │ + │ Face Quality Assessment (Blur, Brightness, Pose) │ + │ │ │ + │ ▼ │ + │ Face Alignment (5 Landmark) │ + │ │ │ + │ ├──► LBPH Extractor ──► LBPH Features (640-D) │ + │ │ │ │ + │ ├──► CNN Extractor ──► CNN Embedding (512-D) │ + │ │ │ │ + │ ▼ ┌───────────────┴───────────────┐ │ + │ │ │ │ + │ ▼ ▼ │ + │ SCORE-LEVEL FUSION wCNN × CNN_sim + wLBPH × LBPH_sim │ + │ │ │ │ + │ ▼ ▼ │ + │ Threshold (ROC + EER) │ + │ │ │ + │ ▼ │ + │ Recognized / Unknown │ + └─────────────────────────────────────────────────────────────┘ + + Quality-Aware Weights: + - wCNN = QualityScore (0.5 - 0.8) + - wLBPH = 1 - wCNN (0.2 - 0.5) + """ + + _instance = None + _system = None + _is_loaded = False + + @classmethod + def get_instance(cls): + """Get singleton instance.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def load(self) -> bool: + """Load EnhancedFaceRecognitionSystem dari ModelBaru.""" + if self._is_loaded: + log.info("[EnhancedFace] System already loaded") + return True + + log.info("[EnhancedFace] Loading EnhancedFaceRecognitionSystem...") + log.info("[EnhancedFace] Architecture: Score-Level Fusion (LBPH + MobileFaceNet)") + + try: + # Import EnhancedFaceRecognitionSystem dari ModelBaru + # sys.path sudah di-set di atas file ini + import importlib + + # Force reload semua modul ModelBaru agar perubahan kode + # (config, faiss_gallery, dll) langsung aktif tanpa restart server + for mod_name in ['config', 'face_preprocessing', 'enhanced_augmentation', + 'faiss_gallery', 'score_fusion', 'threshold_calibrator', + 'enhanced_system']: + if mod_name in sys.modules: + importlib.reload(sys.modules[mod_name]) + log.info(f"[EnhancedFace] Reloaded module: {mod_name}") + + from enhanced_system import EnhancedFaceRecognitionSystem + + # Matikan TF noise + os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2' + + # Buat instance sistem + self._system = EnhancedFaceRecognitionSystem() + + self._is_loaded = True + + # Log info + gallery_size = self._system.get_gallery_size() + threshold = self._system.calibrator.get_threshold() + + log.info("[EnhancedFace] ============================================") + log.info("[EnhancedFace] System loaded successfully!") + log.info(f"[EnhancedFace] Gallery size: {gallery_size} identities") + log.info(f"[EnhancedFace] Threshold: {threshold:.4f}") + log.info("[EnhancedFace] Architecture: Score-Level Fusion") + log.info("[EnhancedFace] ============================================") + + return True + + except ImportError as e: + log.error(f"[EnhancedFace] Import error: {e}") + log.error(f"[EnhancedFace] ModelBaru path: {_MODELBARU_DIR}") + log.error("[EnhancedFace] Pastikan ModelBaru folder ada dan accessible") + # Debug: print sys.path + log.error(f"[EnhancedFace] sys.path: {sys.path[:3]}...") + return False + except Exception as e: + log.error(f"[EnhancedFace] Failed to load system: {e}") + import traceback + traceback.print_exc() + return False + + def enroll( + self, + nim: str, + frames: List[np.ndarray], + metadata: Dict = None + ) -> Dict[str, Any]: + """ + Enroll mahasiswa dengan multi-frame atau single photo. + + Untuk single photo: + - Quality validation ketat + - Augmentation lebih banyak (10 versi) + - Validasi face detection dan alignment + + Args: + nim: NIM mahasiswa + frames: List of BGR images (1 atau lebih) + metadata: Optional metadata dict + + Returns: + Dict dengan enrollment result + """ + if not self._is_loaded: + if not self.load(): + return { + "success": False, + "message": "EnhancedFace system not loaded" + } + + try: + # Ensure nim is string + nim = str(nim) + + # Prepare metadata + meta = metadata or {} + meta['enrolled_via'] = 'enhanced_face_service' + meta['enrolled_at'] = datetime.now().isoformat() + + # Check if single photo mode + is_single_photo = len(frames) == 1 + + if is_single_photo: + # Single photo enrollment with quality check + result = self._enroll_single_photo(nim, frames[0], meta) + else: + # Multi-frame enrollment + success = self._system.enroll(nim, frames, meta) + result = { + "success": success, + "message": f"Berhasil enroll {nim}" if success else f"Gagal enroll {nim}", + "nim": nim, + "gallery_size": self._system.get_gallery_size(), + "num_frames": len(frames) + } + + return result + + except Exception as e: + log.error(f"[EnhancedFace] Enrollment error: {e}") + import traceback + traceback.print_exc() + return { + "success": False, + "message": f"Enrollment error: {str(e)}" + } + + def _enroll_single_photo( + self, + nim: str, + bgr_image: np.ndarray, + metadata: Dict = None + ) -> Dict[str, Any]: + """ + Enroll dengan single photo dan quality validation. + + Args: + nim: NIM mahasiswa + bgr_image: Single BGR image + metadata: Metadata dict + + Returns: + Dict dengan enrollment result + """ + import cv2 + + log.info(f"[EnhancedFace] Single photo enrollment for {nim}") + + # 1. Preprocess dan quality assessment + result = self._system.preprocessor.detect_and_preprocess( + bgr_image, for_enrollment=True + ) + + if result['face'] is None: + return { + "success": False, + "message": "Wajah tidak terdeteksi. Pastikan foto wajah jelas.", + "nim": nim, + "quality_score": 0.0, + "quality_details": None + } + + # Get quality assessment + quality = result['quality'] + quality_score = quality['quality_score'] if quality else 0.0 + + log.info(f"[EnhancedFace] Quality score: {quality_score:.3f}") + + # 2. Validate quality (single photo perlu quality >= 0.6) + if quality_score < 0.6: + quality_details = self._get_quality_details(quality) + return { + "success": False, + "message": f"Quality score ({quality_score:.2f}) terlalu rendah. Minimal 0.60 untuk single photo.", + "nim": nim, + "quality_score": quality_score, + "quality_details": quality_details + } + + # 3. Get aligned face + face = result['aligned_face'] if result['aligned_face'] is not None else result['face'] + + # 4. Generate multiple augmented versions + log.info("[EnhancedFace] Generating augmented versions...") + aug_versions = self._generate_augmented_versions(face, n_versions=10) + + # 5. Extract features dari original + augmented + lighting variants + all_lbph = [] + all_cnn = [] + + # Original + lbph, cnn = self._system._extract_features(face) + all_lbph.append(lbph) + all_cnn.append(cnn) + + # Augmented versions (random: noise, blur, rotasi, brightness, dll.) + for aug_face in aug_versions: + if aug_face is not None: + lbph, cnn = self._system._extract_features(aug_face) + all_lbph.append(lbph) + all_cnn.append(cnn) + + # Lighting variants deterministik (7 kondisi cahaya nyata) + # Ini MENJAMIN gallery mencakup: terang, gelap, sangat gelap, + # overexposed, shadow kiri, shadow kanan, CLAHE agresif + log.info("[EnhancedFace] Generating lighting variants...") + lighting_variants = self._system.augmentation.generate_lighting_variants( + face, include_original=False + ) + for light_face in lighting_variants: + if light_face is not None: + lbph, cnn = self._system._extract_features(light_face) + all_lbph.append(lbph) + all_cnn.append(cnn) + + log.info( + f"[EnhancedFace] Total embeddings: {len(all_lbph)} " + f"(1 original + {len(aug_versions)} random aug + {len(lighting_variants)} lighting variants)" + ) + + # 6. Normalize individual embeddings sebelum averaging + # FIX #1: LBPH wajib pakai L1-norm (sum=1) agar konsisten dengan + # Histogram Intersection di faiss_gallery.match_lbph(). + # L2-norm mengubah distribusi histogram → LBPH similarity turun drastis. + all_lbph_l1 = [] + for lbph_raw in all_lbph: + lbph_sum = lbph_raw.sum() + all_lbph_l1.append(lbph_raw / (lbph_sum + 1e-7)) # L1-norm + + # CNN tetap L2-norm (cosine similarity) + all_cnn_l2 = [] + for cnn_raw in all_cnn: + all_cnn_l2.append(cnn_raw / (np.linalg.norm(cnn_raw) + 1e-7)) # L2-norm + + # Centroid: rata-rata semua embedding yang sudah ternormalisasi + final_lbph = np.mean(all_lbph_l1, axis=0) + final_lbph = final_lbph / (final_lbph.sum() + 1e-7) # L1-norm centroid + + final_cnn = np.mean(all_cnn_l2, axis=0) + final_cnn = final_cnn / (np.linalg.norm(final_cnn) + 1e-7) # L2-norm centroid + + log.info( + f"[EnhancedFace] Single photo templates: {len(all_lbph_l1)} LBPH (L1-norm), " + f"{len(all_cnn_l2)} CNN (L2-norm)" + ) + + # 7. Add to gallery + meta = metadata or {} + meta['quality_score'] = quality_score + meta['num_registrations'] = 1 + meta['is_single_photo'] = True + meta['augmentation_versions'] = len(aug_versions) + 1 + + # FIX #2: Kirim semua template individual agar max-similarity rule aktif. + # Sebelumnya hanya centroid yang disimpan → recognition kurang robust + # saat kondisi foto berbeda dengan kondisi enrollment. + success = self._system.gallery.add_identity( + nim, + final_cnn, + final_lbph, + meta, + cnn_templates=all_cnn_l2, # semua CNN individual (L2-norm) + lbph_templates=all_lbph_l1, # semua LBPH individual (L1-norm) + ) + + if success: + log.info(f"[EnhancedFace] Enrolled {nim} (single photo, Q={quality_score:.3f})") + return { + "success": True, + "message": f"Berhasil enroll {nim} dengan 1 foto berkualitas", + "nim": nim, + "gallery_size": self._system.get_gallery_size(), + "quality_score": quality_score, + "quality_details": self._get_quality_details(quality), + "num_frames": 1, + "augmentation_count": len(aug_versions) + 1 + } + else: + return { + "success": False, + "message": "Gagal menyimpan ke gallery", + "nim": nim, + "quality_score": quality_score + } + + def _generate_augmented_versions( + self, + face: np.ndarray, + n_versions: int = 10 + ) -> List[np.ndarray]: + """ + Generate augmented versions dari satu foto. + + Args: + face: Face crop (BGR) + n_versions: Jumlah versi augmented + + Returns: + List of augmented faces + """ + versions = [] + + for i in range(n_versions): + aug_face = self._system.augmentation.augment(face) + versions.append(aug_face) + + return versions + + def _get_quality_details(self, quality: Dict) -> Optional[Dict]: + """Get detailed quality metrics.""" + if not quality: + return None + + return { + "blur": { + "score": quality.get('blur', {}).get('score', 0), + "ok": quality.get('blur', {}).get('ok', False) + }, + "brightness": { + "score": quality.get('brightness', {}).get('score', 0), + "ok": quality.get('brightness', {}).get('ok', False) + }, + "pose": { + "score": quality.get('pose', {}).get('score', 0), + "ok": quality.get('pose', {}).get('ok', False) + }, + "overall_score": quality.get('quality_score', 0) + } + + def check_quality(self, bgr_image: np.ndarray) -> Dict[str, Any]: + """ + Check quality dari foto sebelum enrollment. + + Args: + bgr_image: BGR image + + Returns: + Dict dengan quality assessment + """ + if not self._is_loaded: + if not self.load(): + return { + "success": False, + "message": "System not loaded" + } + + try: + # Preprocess + result = self._system.preprocessor.detect_and_preprocess( + bgr_image, for_enrollment=True + ) + + if result['face'] is None: + return { + "success": False, + "message": "Wajah tidak terdeteksi", + "face_detected": False, + "quality_score": 0.0 + } + + quality = result['quality'] + quality_score = quality['quality_score'] if quality else 0.0 + + # Get bounding box info + bbox = result['bbox'] + face_size = max(bbox[2], bbox[3]) if bbox else 0 + + # Determine if ready for enrollment + can_enroll = quality_score >= 0.6 + + # Recommendations + recommendations = [] + if quality_score < 0.6: + if quality.get('blur', {}).get('ok') == False: + recommendations.append("Foto terlalu blur - pastikan fokus kamera") + if quality.get('brightness', {}).get('ok') == False: + recommendations.append("Pencahayaan tidak ideal - hindari terlalu terang/gelap") + if quality.get('pose', {}).get('ok') == False: + recommendations.append("Hadap kamera secara frontal") + if face_size < 100: + recommendations.append("Dekatkan wajah ke kamera") + + return { + "success": True, + "face_detected": True, + "quality_score": quality_score, + "can_enroll": can_enroll, + "quality_details": self._get_quality_details(quality), + "face_size": face_size, + "bbox": bbox, + "recommendations": recommendations, + "message": "Foto siap untuk enrollment" if can_enroll else "Quality score terlalu rendah" + } + + except Exception as e: + log.error(f"[EnhancedFace] Quality check error: {e}") + return { + "success": False, + "message": f"Quality check error: {str(e)}" + } + + def enroll_from_image( + self, + nim: str, + bgr_image: np.ndarray, + metadata: Dict = None + ) -> Dict[str, Any]: + """Enroll dari single image.""" + return self.enroll(nim, [bgr_image], metadata) + + def recognize( + self, + bgr_image: np.ndarray, + update_template: bool = True + ) -> Dict[str, Any]: + """ + Recognize wajah dalam gambar. + + Args: + bgr_image: BGR image + update_template: Update gallery template jika similarity >= 0.98 + + Returns: + Dict dengan recognition result + """ + if not self._is_loaded: + if not self.load(): + return self._error_response("EnhancedFace system not loaded") + + try: + # Recognize + result = self._system.recognize(bgr_image, update_template=update_template) + + # Diagnostic breakdown for real mobile captures. The mobile UI + # displays only the final confidence, which hides whether a low + # score comes from CNN, LBPH, quality, or fusion. + log.info( + "[EnhancedFace][SCORE] cnn=%.4f lbph=%.4f final=%.4f threshold=%.4f " + "recognized=%s nim=%s image_shape=%s", + float(result.cnn_similarity), + float(result.lbph_similarity), + float(result.similarity), + float(self._system.calibrator.get_threshold()), + bool(result.is_recognized), + result.nim, + getattr(bgr_image, "shape", None), + ) + + if result.is_recognized: + return { + "success": True, + "is_known": True, + "nim": result.nim, + "person_name": result.nim, # NIM is the identifier + "confidence": float(result.similarity), + "cnn_similarity": float(result.cnn_similarity), + "lbph_similarity": float(result.lbph_similarity), + "final_score": float(result.similarity), + "message": f"Recognized: {result.nim}", + "threshold": float(self._system.calibrator.get_threshold()), + "top_k_results": [ + { + "nim": r['nim'], + "fused_score": float(r['fused']), + "cnn_sim": float(r['cnn_sim']), + "lbph_sim": float(r['lbph_sim']) + } + for r in result.top_k_results + ] if result.top_k_results else [] + } + else: + custom_msg = getattr(result, 'message', '') or f"Unknown (similarity: {result.similarity:.4f})" + return { + "success": False if "masker" in custom_msg.lower() else True, + "is_known": False, + "nim": None, + "person_name": "Unknown", + "confidence": float(result.similarity), + "cnn_similarity": float(result.cnn_similarity), + "lbph_similarity": float(result.lbph_similarity), + "final_score": float(result.similarity), + "message": custom_msg, + "threshold": float(self._system.calibrator.get_threshold()), + "top_k_results": [ + { + "nim": r['nim'], + "fused_score": float(r['fused']), + "cnn_sim": float(r['cnn_sim']), + "lbph_sim": float(r['lbph_sim']) + } + for r in result.top_k_results + ] if result.top_k_results else [] + } + + except Exception as e: + log.error(f"[EnhancedFace] Recognition error: {e}") + import traceback + traceback.print_exc() + return self._error_response(f"Recognition error: {str(e)}") + + def verify( + self, + bgr_image: np.ndarray, + nim: str + ) -> Dict[str, Any]: + """ + Verify wajah terhadap NIM tertentu. + + Args: + bgr_image: BGR image + nim: NIM untuk verifikasi + + Returns: + Dict dengan verification result + """ + if not self._is_loaded: + if not self.load(): + return { + "valid": False, + "message": "EnhancedFace system not loaded" + } + + try: + is_verified, similarity = self._system.verify(bgr_image, nim) + + return { + "valid": is_verified, + "nim": nim, + "similarity": float(similarity), + "threshold": float(self._system.calibrator.get_threshold()), + "message": f"{'MATCH' if is_verified else 'NO MATCH'} (sim={similarity:.4f})" + } + + except Exception as e: + log.error(f"[EnhancedFace] Verify error: {e}") + return { + "valid": False, + "nim": nim, + "similarity": 0.0, + "message": f"Verify error: {str(e)}" + } + + def get_gallery_size(self) -> int: + """Get number of enrolled identities.""" + if not self._is_loaded: + return 0 + return self._system.get_gallery_size() + + def list_identities(self) -> List[str]: + """List semua enrolled identities.""" + if not self._is_loaded: + return [] + return self._system.list_identities() + + def remove_identity(self, nim: str) -> bool: + """Remove identity dari gallery.""" + if not self._is_loaded: + return False + return self._system.remove_identity(nim) + + def get_identity_info(self, nim: str) -> Optional[Dict[str, Any]]: + """Get info tentang identity tertentu.""" + if not self._is_loaded or self._system.gallery is None: + return None + + info = self._system.gallery.get_identity_info(nim) + if info: + return { + "nim": nim, + "quality_score": float(info.quality_score), + "enrollment_date": info.enrollment_date, + "num_registrations": info.num_registrations + } + return None + + def health(self) -> Dict[str, Any]: + """Return health status.""" + if not self._is_loaded: + return { + "model_loaded": False, + "model_type": "enhanced_score_fusion", + "model_name": "EnhancedFaceRecognitionSystem", + "architecture": "Score-Level Fusion (LBPH + MobileFaceNet)", + "embedding_or_classification": "embedding", + "features": [ + "YuNet face detection", + "Quality Assessment (Blur, Brightness, Pose)", + "Face Alignment (5 Landmark)", + "LBPH features (640-D)", + "MobileFaceNet embedding (512-D) via ONNX", + "Score-Level Fusion with Quality-Aware Weights", + "FAISS Gallery for fast search", + "Incremental Template Update (sim >= 0.98)", + "Multi-frame enrollment (15 → 5 best frames)", + "Top-K results" + ], + "paths": { + "modelbaruu_dir": str(_MODELBARU_DIR), + "models_dir": str(_MODELS_DIR), + "face_mahasiswa_dir": str(_FACE_MAHASISWA_DIR) + } + } + + return { + "model_loaded": self._is_loaded, + "model_type": "enhanced_score_fusion", + "model_name": "EnhancedFaceRecognitionSystem", + "architecture": "Score-Level Fusion (LBPH + MobileFaceNet)", + "embedding_or_classification": "embedding", + "gallery_size": self._system.get_gallery_size(), + "threshold": float(self._system.calibrator.get_threshold()), + "identities": self._system.list_identities(), + "features": [ + "YuNet face detection", + "Quality Assessment (Blur, Brightness, Pose)", + "Face Alignment (5 Landmark)", + "LBPH features (640-D)", + "MobileFaceNet embedding (512-D) via ONNX", + "Score-Level Fusion with Quality-Aware Weights", + "FAISS Gallery for fast search", + "Incremental Template Update (sim >= 0.98)", + "Multi-frame enrollment (15 → 5 best frames)", + "Top-K results" + ], + "paths": { + "modelbaruu_dir": str(_MODELBARU_DIR), + "models_dir": str(_MODELS_DIR), + "face_mahasiswa_dir": str(_FACE_MAHASISWA_DIR) + } + } + + def _error_response(self, message: str) -> Dict[str, Any]: + """Return error response.""" + return { + "success": False, + "is_known": False, + "nim": None, + "person_name": "Unknown", + "confidence": 0.0, + "cnn_similarity": 0.0, + "lbph_similarity": 0.0, + "final_score": 0.0, + "message": message, + "error": True + } + + +# ─── Singleton Functions ─────────────────────────────────────────────────────── + +_wrapper_instance = None + + +def get_enhanced_face_wrapper() -> EnhancedFaceRecognitionWrapper: + """Get singleton EnhancedFaceRecognitionWrapper.""" + global _wrapper_instance + if _wrapper_instance is None: + _wrapper_instance = EnhancedFaceRecognitionWrapper() + return _wrapper_instance + + +def preload_enhanced_face_model() -> bool: + """Pre-load EnhancedFaceRecognitionSystem saat startup.""" + try: + wrapper = get_enhanced_face_wrapper() + return wrapper.load() + except Exception as e: + log.warning(f"[EnhancedFace] Pre-load failed: {e}") + import traceback + traceback.print_exc() + return False + + +def is_enhanced_face_available() -> bool: + """Check if EnhancedFace model is available.""" + wrapper = get_enhanced_face_wrapper() + return wrapper._is_loaded diff --git a/facerecognition_presensi/backend/app/services/enrollment_backend_service.py b/facerecognition_presensi/backend/app/services/enrollment_backend_service.py new file mode 100644 index 0000000..fe4d84f --- /dev/null +++ b/facerecognition_presensi/backend/app/services/enrollment_backend_service.py @@ -0,0 +1,551 @@ +""" +enrollment_backend_service.py +============================ +Backend service untuk Face Enrollment System. + +Integrasi dengan FastAPI backend untuk: +- Enroll mahasiswa baru (dari Android) +- Recognize wajah saat presensi +- Auto-enroll dari database + +Usage: + from enrollment_backend_service import EnrollmentBackendService + service = EnrollmentBackendService() + service.initialize() # Setup FAISS dari database +""" + +import base64 +import logging +import os +from pathlib import Path +from typing import Optional, List, Tuple, Dict +from dataclasses import dataclass + +import numpy as np + +log = logging.getLogger(__name__) + +# Add paths - ModelBaru (BARU) +_CURRENT_FILE = Path(__file__).resolve() +_PROJECT_ROOT = _CURRENT_FILE.parent.parent.parent # face_recognition_system/ +_MODELBARU_DIR = _PROJECT_ROOT / "ModelBaru" +_MODELS_DIR = _MODELBARU_DIR / "models" +_FACE_MAHASISWA_DIR = _MODELBARU_DIR / "face_mahasiswa" + +import sys +sys.path.insert(0, str(_MODELBARU_DIR)) +sys.path.insert(0, str(_MODELS_DIR)) + + +# ============================================================ +# DATA CLASSES +# ============================================================ + +@dataclass +class RecognitionResult: + """Result dari face recognition.""" + success: bool + name: str + similarity: float + bbox: List[int] + message: str + metadata: Dict = None + + +@dataclass +class EnrollmentResult: + """Result dari enrollment.""" + success: bool + name: str + message: str + + +# ============================================================ +# BACKEND SERVICE +# ============================================================ + +class EnrollmentBackendService: + """ + Backend service untuk face enrollment dan recognition. + + Methods: + - initialize(): Setup FAISS dari database + - enroll_from_db(): Enroll semua mahasiswa dari database + - enroll_single(): Enroll satu mahasiswa + - recognize_from_base64(): Recognize dari base64 image + - recognize_from_file(): Recognize dari file + """ + + def __init__(self): + self.detector = None + self.extractor = None + self.index = None + self._initialized = False + self._embedding_dim = 1280 + + def initialize(self, db_session=None) -> bool: + """ + Initialize semua komponen. + + Args: + db_session: SQLAlchemy session (optional) + + Returns: + True jika berhasil + """ + if self._initialized: + return True + + log.info("[EnrollmentBackend] Initializing...") + + # Import FAISS + try: + import faiss + except ImportError: + log.error("FAISS not installed. Run: pip install faiss-cpu") + return False + + # Load detector + try: + self.detector = self._create_detector() + if not self.detector.load(): + log.error("Failed to load face detector") + return False + except Exception as e: + log.error(f"Detector error: {e}") + return False + + # Load extractor + try: + self.extractor = self._create_extractor() + if not self.extractor.load(): + log.error("Failed to load embedding extractor") + return False + except Exception as e: + log.error(f"Extractor error: {e}") + return False + + # Load or create index + self.index = self._create_index() + if not self.index.load(): + log.error("Failed to load FAISS index") + return False + + self._initialized = True + log.info("[EnrollmentBackend] Initialized successfully") + + # Auto-enroll dari database jika ada session + if db_session: + self.enroll_from_database(db_session) + + return True + + def _create_detector(self): + """Create face detector.""" + class FaceDetector: + def __init__(self): + self.yunet = None + self.haar = None + + def load(self) -> bool: + yunet_path = _MODELS_DIR / "face_detection_yunet_2023mar.onnx" + if yunet_path.exists(): + try: + self.yunet = cv2.FaceDetectorYN.create( + str(yunet_path), "", (320, 320), + score_threshold=0.4, nms_threshold=0.3, top_k=5 + ) + log.info("[Detector] YuNet loaded") + return True + except Exception as e: + log.warning(f"[Detector] YuNet failed: {e}") + + haar_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml" + cascade = cv2.CascadeClassifier(haar_path) + if not cascade.empty(): + self.haar = cascade + log.info("[Detector] Haar loaded") + return True + return False + + def detect(self, image): + h, w = image.shape[:2] + + if self.yunet: + self.yunet.setInputSize((w, h)) + _, faces = self.yunet.detect(image) + if faces is not None and len(faces) > 0: + best = faces[np.argmax(faces[:, 14])] + x, y, bw, bh = map(int, best[:4]) + if bw >= 60 and bh >= 60: + return image[y:y+bh, x:x+bw], [x, y, bw, bh] + + if self.haar: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + gray = cv2.equalizeHist(gray) + faces = self.haar.detectMultiScale(gray, 1.1, 5, minSize=(60, 60)) + if len(faces) > 0: + x, y, w, h = max(faces, key=lambda f: f[2]*f[3]) + pad = int(0.1 * min(w, h)) + x1, y1 = max(0, x-pad), max(0, y-pad) + x2, y2 = min(w, x+w+pad), min(h, y+h+pad) + return image[y1:y2, x1:x2], [x1, y1, x2-x1, y2-y1] + + return None + + return FaceDetector() + + def _create_extractor(self): + """Create embedding extractor.""" + import tensorflow as tf + os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' + + class EmbeddingExtractor: + def __init__(self): + self.model = None + self.img_size = (224, 224) + + def load(self) -> bool: + try: + base = tf.keras.applications.MobileNetV2( + input_shape=(224, 224, 3), + weights='imagenet', + include_top=False, + pooling='avg' + ) + for layer in base.layers: + layer.trainable = False + self.model = tf.keras.Model(inputs=base.input, outputs=base.output) + log.info("[Extractor] MobileNetV2 loaded") + return True + except Exception as e: + log.error(f"[Extractor] Failed: {e}") + return False + + def extract(self, face): + if not self.model: + return None + try: + face = cv2.resize(face, self.img_size) + face = cv2.cvtColor(face, cv2.COLOR_BGR2RGB) + face = face.astype(np.float32) + face = tf.keras.applications.mobilenet_v2.preprocess_input(face) + emb = self.model.predict(face[np.newaxis], verbose=0)[0] + return (emb / np.linalg.norm(emb)).astype(np.float32) + except Exception as e: + log.error(f"[Extractor] Error: {e}") + return None + + return EmbeddingExtractor() + + def _create_index(self): + """Create FAISS index.""" + import faiss + import pickle + + class EmbeddingIndex: + def __init__(self, dim): + self.dim = dim + self.index = None + self.labels = [] + self.metadata = {} + self.index_path = _MODELS_DIR / "backend_index.bin" + self.labels_path = _MODELS_DIR / "backend_labels.pkl" + self.meta_path = _MODELS_DIR / "backend_metadata.pkl" + + def load(self) -> bool: + try: + if self.index_path.exists(): + self.index = faiss.read_index(str(self.index_path)) + with open(self.labels_path, 'rb') as f: + self.labels = pickle.load(f).get('labels', []) + if self.meta_path.exists(): + with open(self.meta_path, 'rb') as f: + self.metadata = pickle.load(f) + log.info(f"[Index] Loaded {len(self.labels)} embeddings") + else: + self.index = faiss.IndexFlatIP(self.dim) + log.info("[Index] Created new index") + return True + except Exception as e: + log.error(f"[Index] Load error: {e}") + return False + + def save(self): + try: + faiss.write_index(self.index, str(self.index_path)) + with open(self.labels_path, 'wb') as f: + pickle.dump({'labels': self.labels}, f) + with open(self.meta_path, 'wb') as f: + pickle.dump(self.metadata, f) + log.info(f"[Index] Saved {len(self.labels)} embeddings") + except Exception as e: + log.error(f"[Index] Save error: {e}") + + def add(self, emb, label, meta=None): + self.index.add(emb.reshape(1, -1)) + self.labels.append(label) + if meta: + self.metadata[label] = meta + + def search(self, emb, k=3): + if not self.index or self.index.ntotal == 0: + return [] + dists, idxs = self.index.search(emb.reshape(1, -1), min(k, self.index.ntotal)) + return [(self.labels[i], float(d)) for d, i in zip(dists[0], idxs[0]) if i < len(self.labels)] + + return EmbeddingIndex(self._embedding_dim) + + def enroll_from_database(self, db_session) -> int: + """ + Enroll semua mahasiswa dari database. + + Args: + db_session: SQLAlchemy session + + Returns: + Jumlah mahasiswa yang berhasil di-enroll + """ + log.info("[EnrollmentBackend] Enrolling from database...") + + try: + # Import model + from app.models.mahasiswa import Mahasiswa + + # Get all mahasiswa with foto_profil + mahasiswa_list = db_session.query(Mahasiswa).filter( + Mahasiswa.foto_profil.isnot(None) + ).all() + + enrolled = 0 + skipped = 0 + + for mhs in mahasiswa_list: + # Skip if already enrolled + if mhs.nim in self.index.labels: + skipped += 1 + continue + + # Get foto path + foto_path = _PROJECT_ROOT / mhs.foto_profil.lstrip('/') + if not foto_path.exists(): + log.warning(f"[EnrollmentBackend] Foto not found: {foto_path}") + skipped += 1 + continue + + # Load and detect face + img = cv2.imread(str(foto_path)) + if img is None: + skipped += 1 + continue + + result = self.detector.detect(img) + if result is None: + log.warning(f"[EnrollmentBackend] No face in {mhs.nim}") + skipped += 1 + continue + + face, bbox = result + + # Extract embedding + emb = self.extractor.extract(face) + if emb is None: + skipped += 1 + continue + + # Add to index + self.index.add(emb, mhs.nim, { + "nama": mhs.nama_mahasiswa, + "nim": mhs.nim, + "id": mhs.id_mahasiswa + }) + + enrolled += 1 + log.info(f"[EnrollmentBackend] Enrolled: {mhs.nim} - {mhs.nama_mahasiswa}") + + # Save index + self.index.save() + + log.info(f"[EnrollmentBackend] Enrollment complete: {enrolled} enrolled, {skipped} skipped") + return enrolled + + except Exception as e: + log.error(f"[EnrollmentBackend] Database enrollment failed: {e}") + import traceback + traceback.print_exc() + return 0 + + def enroll_single( + self, + name: str, + image_path: str = None, + base64_image: str = None, + metadata: dict = None + ) -> EnrollmentResult: + """ + Enroll satu mahasiswa baru. + + Args: + name: NIM atau nama + image_path: Path ke file gambar + base64_image: Base64 encoded image + metadata: Info tambahan + + Returns: + EnrollmentResult + """ + if not self._initialized: + return EnrollmentResult(False, name, "System not initialized") + + # Load image + if image_path: + img = cv2.imread(str(image_path)) + elif base64_image: + try: + if "," in base64_image: + base64_image = base64_image.split(",", 1)[1] + data = base64.b64decode(base64_image) + nparr = np.frombuffer(data, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + except Exception as e: + return EnrollmentResult(False, name, f"Base64 decode error: {e}") + else: + return EnrollmentResult(False, name, "No image provided") + + if img is None: + return EnrollmentResult(False, name, "Cannot load image") + + # Detect face + result = self.detector.detect(img) + if result is None: + return EnrollmentResult(False, name, "No face detected") + + face, bbox = result + + # Extract embedding + emb = self.extractor.extract(face) + if emb is None: + return EnrollmentResult(False, name, "Failed to extract embedding") + + # Add to index + self.index.add(emb, name, metadata or {}) + self.index.save() + + log.info(f"[EnrollmentBackend] Enrolled: {name}") + return EnrollmentResult(True, name, f"Successfully enrolled: {name}") + + def recognize( + self, + image_path: str = None, + base64_image: str = None, + threshold: float = 0.5, + return_top_k: int = 3 + ) -> Tuple[bool, str, float, List[dict]]: + """ + Recognize wajah. + + Args: + image_path: Path ke file gambar + base64_image: Base64 encoded image + threshold: Minimum similarity threshold + return_top_k: Number of results to return + + Returns: + (success, name, similarity, top_results) + """ + if not self._initialized: + return False, "System not initialized", 0.0, [] + + # Load image + if image_path: + img = cv2.imread(str(image_path)) + elif base64_image: + try: + if "," in base64_image: + base64_image = base64_image.split(",", 1)[1] + data = base64.b64decode(base64_image) + nparr = np.frombuffer(data, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + except Exception as e: + return False, f"Base64 error: {e}", 0.0, [] + else: + return False, "No image provided", 0.0, [] + + if img is None: + return False, "Cannot load image", 0.0, [] + + # Detect face + result = self.detector.detect(img) + if result is None: + return False, "No face detected", 0.0, [] + + face, bbox = result + + # Extract embedding + emb = self.extractor.extract(face) + if emb is None: + return False, "Embedding extraction failed", 0.0, [] + + # Search + if self.index.index is None or self.index.index.ntotal == 0: + return False, "No enrolled faces", 0.0, [] + + results = self.index.search(emb, k=return_top_k) + + if not results: + return False, "Search failed", 0.0, [] + + # Format results + top_results = [] + for name, similarity in results: + meta = self.index.metadata.get(name, {}) + top_results.append({ + "name": name, + "similarity": similarity, + "metadata": meta + }) + + best_name, best_sim = results[0] + + # Check threshold + if best_sim < threshold: + return False, f"Low confidence ({best_sim:.3f})", best_sim, top_results + + log.info(f"[EnrollmentBackend] Recognized: {best_name} ({best_sim:.4f})") + return True, best_name, best_sim, top_results + + def is_ready(self) -> bool: + """Check if system is ready.""" + return self._initialized + + def get_stats(self) -> dict: + """Get enrollment stats.""" + if not self._initialized: + return {"ready": False} + + return { + "ready": True, + "total_enrolled": len(self.index.labels), + "index_size": self.index.index.ntotal if self.index.index else 0 + } + + +# ============================================================ +# SINGLETON INSTANCE +# ============================================================ + +_enrollment_service = None + + +def get_enrollment_service() -> EnrollmentBackendService: + """Get singleton instance.""" + global _enrollment_service + if _enrollment_service is None: + _enrollment_service = EnrollmentBackendService() + return _enrollment_service + + +def initialize_enrollment_service(db_session=None) -> bool: + """Initialize enrollment service.""" + service = get_enrollment_service() + return service.initialize(db_session) diff --git a/facerecognition_presensi/backend/app/services/face_service.py b/facerecognition_presensi/backend/app/services/face_service.py new file mode 100644 index 0000000..e69de29 diff --git a/facerecognition_presensi/backend/app/services/ml_service.py b/facerecognition_presensi/backend/app/services/ml_service.py new file mode 100644 index 0000000..99b1348 --- /dev/null +++ b/facerecognition_presensi/backend/app/services/ml_service.py @@ -0,0 +1,907 @@ +""" +ml_service.py +============= +Bridge service untuk mengintegrasikan Face Recognition Pipeline +dengan backend FastAPI. + +Mendukung DUA model: +1. Classification Model (LBPH + MobileNetV2 + Softmax) + - Training-based recognition + - Requires retraining for new students + +2. ArcFace Model (LBPH + MobileNetV2 + ArcFace Loss + FAISS) + - Embedding-based recognition + - Zero-shot enrollment (no retraining needed) + - FAISS for similarity search + +Author: Face Recognition Skripsi +""" + +import logging +import sys +import os +from pathlib import Path +from typing import Dict, Any + +log = logging.getLogger(__name__) + +# Path absolut ke folder ModelBaru (BARU) +_CURRENT_FILE = Path(__file__).resolve() +_BACKEND_DIR = _CURRENT_FILE.parent.parent.parent # backend/ +_ROOT_DIR = _BACKEND_DIR.parent # face_recognition_system/ +_MODELBARU_DIR = _ROOT_DIR / "ModelBaru" # ModelBaru/ +_MODELS_DIR = _MODELBARU_DIR / "models" # ModelBaru/models/ +_FACE_MAHASISWA_DIR = _MODELBARU_DIR / "face_mahasiswa" # ModelBaru/face_mahasiswa/ + +log.info(f"[ML] Backend dir: {_BACKEND_DIR}") +log.info(f"[ML] ModelBaru dir: {_MODELBARU_DIR}") +log.info(f"[ML] Models dir: {_MODELS_DIR}") + +# Singleton models +_classification_model = None +_arcface_model = None +_enhanced_model = None + +# Get active model type from environment or settings +def _get_model_type() -> str: + """Get active model type from environment variable or settings.""" + # Try settings first + try: + from app.config.settings import settings + return settings.FACE_MODEL_TYPE.lower() + except Exception: + pass + + # Fallback to environment + return os.getenv("FACE_MODEL_TYPE", "enhanced").lower() + + +def _ensure_ml_path(): + """Tambahkan folder ModelBaru ke sys.path.""" + paths_to_add = [ + str(_MODELS_DIR), + str(_MODELBARU_DIR), + str(_ROOT_DIR / "ML"), + ] + + for path in paths_to_add: + if path not in sys.path: + sys.path.insert(0, path) + log.info(f"[ML] Added to sys.path: {path}") + else: + log.debug(f"[ML] Already in sys.path: {path}") + + +# ============================================================ +# CLASSIFICATION MODEL (LBPH + MobileNetV2 + Softmax) +# ============================================================ + +class HybridFaceRecognitionWrapper: + """ + Wrapper untuk Hybrid Face Recognition Model (Classification). + + Model: LBPH (640-D) + MobileNetV2 (1280-D) = 1920-D Feature Fusion + Output: Softmax classification + """ + + def __init__(self): + self._engine = None + self._label_encoder = {} + self._is_loaded = False + self._model_path = str(_MODELS_DIR / "hybrid_face_model.keras") + self._label_path = str(_MODELS_DIR / "label_encoder.pkl") + self._engine_class = None + + log.info(f"[ML-Classification] Model path: {self._model_path}") + + def load(self) -> bool: + """Load the hybrid face recognition model.""" + if self._is_loaded: + log.info("[ML-Classification] Model already loaded") + return True + + _ensure_ml_path() + + # Use importlib to load modules from ML/new_pipeline/models + import importlib.util + + def load_module_from_path(module_name: str, file_path: Path): + """Load a module from a specific file path.""" + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + return None + + # Try to load hybrid_inference + hybrid_inference_path = _MODELS_DIR / "hybrid_inference.py" + if hybrid_inference_path.exists(): + try: + module = load_module_from_path("hybrid_inference", hybrid_inference_path) + if module and hasattr(module, 'HybridInferenceEngine'): + self._engine_class = module.HybridInferenceEngine + log.info("[ML-Classification] Engine class loaded via importlib") + else: + raise ImportError("HybridInferenceEngine not found in module") + except Exception as e: + log.error(f"[ML-Classification] Failed to load hybrid_inference: {e}") + return False + else: + log.error(f"[ML-Classification] hybrid_inference.py not found at {hybrid_inference_path}") + return False + + try: + self._engine = self._engine_class( + model_path=self._model_path, + label_path=self._label_path, + confidence_threshold=0.40 + ) + + success = self._engine.load() + + if success: + self._is_loaded = True + log.info("[ML-Classification] ✅ Model loaded successfully!") + else: + log.error("[ML-Classification] Model load returned False") + + return success + + except Exception as e: + log.error(f"[ML-Classification] Failed to load engine: {e}") + import traceback + traceback.print_exc() + return False + + def recognize(self, bgr_image) -> Dict[str, Any]: + """ + Kenali wajah dalam gambar. + + Returns: + Dict dengan keys: + - is_known: bool + - person_name: str (NIM or name) + - confidence: float (0-1) + - lbph_confidence: float + - cnn_similarity: float + - message: str + - detector: str + - inference_time_ms: float + - bbox: list + """ + if not self._is_loaded: + if not self.load(): + return self._error_response("Model not loaded") + + try: + import cv2 + log.info(f"[ML-Classification] Recognizing face from image shape: {bgr_image.shape}") + + # Try multiple approaches + approaches = [ + ("original", bgr_image), + ("flipped_h", cv2.flip(bgr_image, 1)), + ] + + best_result = None + best_confidence_result = None + + for name, img in approaches: + try: + result = self._engine.recognize(img) + + if best_confidence_result is None or result.confidence > best_confidence_result.confidence: + best_confidence_result = result + + if result.recognized: + best_result = result + if result.confidence > 0.6: + break + except Exception as approach_err: + log.warning(f"[ML-Classification] Approach {name} failed: {approach_err}") + + if best_result is None: + if best_confidence_result is not None: + best_result = best_confidence_result + else: + return self._error_response("All detection approaches failed") + + return { + "is_known": best_result.recognized, + "person_name": best_result.person_name, + "confidence": float(best_result.confidence), + "lbph_confidence": float(best_result.confidence), + "cnn_similarity": float(best_result.confidence), + "message": best_result.message, + "detector": best_result.detector, + "inference_time_ms": float(best_result.inference_time_ms), + "bbox": best_result.bbox, + "predictions": best_result.predictions if hasattr(best_result, 'predictions') else [], + "model_type": "classification" + } + + except Exception as e: + log.error(f"[ML-Classification] Recognition error: {e}") + import traceback + traceback.print_exc() + return self._error_response(f"Recognition error: {str(e)}") + + def _error_response(self, message: str) -> Dict[str, Any]: + """Return error response.""" + return { + "is_known": False, + "person_name": "Unknown", + "confidence": 0.0, + "lbph_confidence": 0.0, + "cnn_similarity": 0.0, + "message": message, + "detector": "error", + "inference_time_ms": 0.0, + "bbox": [0, 0, 0, 0], + "model_type": "classification" + } + + def health(self) -> Dict[str, Any]: + """Return health status.""" + return { + "model_loaded": self._is_loaded, + "model_type": "classification", + "model_name": "Hybrid LBPH + MobileNetV2", + "architecture": "Softmax classification", + "embedding_or_classification": "classification", + "features": [ + "LBPH texture features (640-D)", + "MobileNetV2 deep features (1280-D)", + "Feature-level fusion (1920-D)", + "Eye landmark face alignment", + "CLAHE preprocessing" + ], + "paths": { + "model": self._model_path, + "labels": self._label_path, + "pipeline": str(_MODELBARU_DIR) + } + } + + +# ============================================================ +# ARCFACE MODEL (Embedding + FAISS) +# ============================================================ + +class ArcFaceRecognitionWrapper: + """ + Wrapper untuk Oneshot Face Recognition dengan LBPH + MobileNetV2. + + Model: LBPH + MobileNetV2 untuk extract embedding (tanpa training) + Output: 1920-D embeddings + FAISS similarity search + + Features: + - Oneshot enrollment (no retraining) + - FAISS for fast nearest neighbor search + - Menggunakan model hybrid yang sudah ada + """ + + def __init__(self): + self._enrollment_system = None + self._is_loaded = False + log.info("[ML-Oneshot] Initialized (uses Hybrid LBPH + MobileNetV2)") + + def load(self) -> bool: + """Load the enrollment system for oneshot recognition.""" + if self._is_loaded: + log.info("[ML-Oneshot] System already loaded") + return True + + try: + # Import enrollment system + from app.routes.arcface_enrollment import HybridEnrollmentSystem + + self._enrollment_system = HybridEnrollmentSystem.get_instance() + + if not self._enrollment_system._is_loaded: + success = self._enrollment_system.load() + if not success: + log.error("[ML-Oneshot] Failed to load enrollment system") + return False + + self._is_loaded = True + log.info("[ML-Oneshot] ✅ Hybrid LBPH + MobileNetV2 loaded successfully!") + stats = self._enrollment_system.get_stats() + log.info(f"[ML-Oneshot] FAISS index: {stats.get('total_embeddings', 0)} embeddings") + + return True + + except Exception as e: + log.error(f"[ML-Oneshot] Failed to load: {e}") + import traceback + traceback.print_exc() + return False + + def _search_faiss(self, embedding) -> tuple: + """Search embedding in FAISS index.""" + import numpy as np + + try: + # Get FAISS index from enrollment system + if self._enrollment_system._faiss_index is None: + return None, 0.0 + + index = self._enrollment_system._faiss_index + metadata = self._enrollment_system._metadata + + if index.ntotal == 0: + return None, 0.0 + + # Normalize query embedding + query = embedding.reshape(1, -1).astype(np.float32) + norm = np.linalg.norm(query) + if norm > 0: + query = query / norm + + # Search FAISS + k = min(5, index.ntotal) # Search top 5 + scores, indices = index.search(query, k) + + # Get best match + best_idx = indices[0][0] + best_score = scores[0][0] + + # Skip if deleted + if best_idx >= 0 and str(best_idx) in metadata: + entry = metadata[str(best_idx)] + if not entry.get('deleted', False): + return entry, float(best_score) + + return None, 0.0 + + except Exception as e: + log.error(f"[ML-Oneshot] FAISS search failed: {e}") + return None, 0.0 + + def recognize(self, bgr_image) -> Dict[str, Any]: + """ + Kenali wajah dalam gambar menggunakan oneshot LBPH + MobileNetV2. + + Returns: + Dict dengan keys: + - is_known: bool + - person_name: str + - confidence: float (similarity 0-1) + - person_id: str + - message: str + - detector: str + - inference_time_ms: float + - bbox: list + """ + import time + start_time = time.time() + + if not self._is_loaded: + if not self.load(): + return self._error_response("System not loaded") + + try: + import cv2 + log.info(f"[ML-Oneshot] Recognizing face from image shape: {bgr_image.shape}") + + # Detect face + face_result = self._enrollment_system._detect_face(bgr_image) + if face_result is None: + return self._error_response("No face detected") + + face_crop = face_result['face_crop'] + bbox = face_result['bbox'] + log.info(f"[ML-Oneshot] Face detected: bbox={bbox}") + + # Extract embedding using hybrid model + embedding = self._enrollment_system.extract_embedding(face_crop) + if embedding is None: + return self._error_response("Failed to extract embedding") + + # Search in FAISS + match, score = self._search_faiss(embedding) + + inference_time = (time.time() - start_time) * 1000 + + if match is not None: + return { + "is_known": True, + "person_name": match.get('person_name', 'Unknown'), + "person_id": match.get('person_id', 'Unknown'), + "confidence": float(score), + "lbph_confidence": float(score), + "cnn_similarity": float(score), + "message": f"Recognized: {match.get('person_name')}", + "detector": "yunet", + "inference_time_ms": float(inference_time), + "bbox": bbox, + "predictions": [], + "model_type": "arcface" + } + else: + return { + "is_known": False, + "person_name": "Unknown", + "person_id": "Unknown", + "confidence": 0.0, + "lbph_confidence": 0.0, + "cnn_similarity": 0.0, + "message": "Face not recognized - enroll first!", + "detector": "yunet", + "inference_time_ms": float(inference_time), + "bbox": bbox, + "predictions": [], + "model_type": "arcface" + } + + except Exception as e: + log.error(f"[ML-Oneshot] Recognition error: {e}") + import traceback + traceback.print_exc() + return self._error_response(f"Recognition error: {str(e)}") + + def _error_response(self, message: str) -> Dict[str, Any]: + """Return error response.""" + return { + "is_known": False, + "person_name": "Unknown", + "person_id": "Unknown", + "confidence": 0.0, + "lbph_confidence": 0.0, + "cnn_similarity": 0.0, + "message": message, + "detector": "error", + "inference_time_ms": 0.0, + "bbox": [0, 0, 0, 0], + "model_type": "arcface" + } + + def health(self) -> Dict[str, Any]: + """Return health status.""" + stats = {} + if self._enrollment_system: + stats = self._enrollment_system.get_stats() + + return { + "model_loaded": self._is_loaded, + "model_type": "arcface", + "model_name": "Hybrid LBPH + MobileNetV2 (Oneshot)", + "architecture": "Embedding (1920-D) + FAISS search", + "embedding_or_classification": "embedding", + "features": [ + "LBPH texture features (640-D)", + "MobileNetV2 deep features (1280-D)", + "Feature-level fusion (1920-D)", + "FAISS IndexFlatIP for fast search", + "Oneshot enrollment (no retraining)" + ], + "index_stats": stats, + "paths": { + "pipeline": str(_MODELBARU_DIR), + "index": str(_MODELS_DIR / "face_embeddings.index") + } + } + + def enroll( + self, + person_id: str, + person_name: str, + bgr_image + ) -> Dict[str, Any]: + """ + Enroll new person (Oneshot mode). + + Args: + person_id: Unique identifier (e.g., NIM) + person_name: Display name + bgr_image: BGR image + + Returns: + Dict dengan enrollment result + """ + if not self._is_loaded: + if not self.load(): + return {"success": False, "message": "System not loaded"} + + try: + success = self._enrollment_system.enroll(person_id, person_name, bgr_image) + return { + "success": success, + "message": f"Enrolled: {person_name} ({person_id})" if success else "Enrollment failed" + } + except Exception as e: + log.error(f"[ML-Oneshot] Enrollment error: {e}") + return {"success": False, "message": str(e)} + + +# ============================================================ +# ENHANCED FACE MODEL (Score-Level Fusion) +# ============================================================ + +class EnhancedFaceRecognitionWrapper: + """ + Wrapper untuk EnhancedFaceRecognitionSystem dari ModelBaru. + + Arsitektur Score-Level Fusion: + - LBPH features (640-D) + - MobileFaceNet embedding (512-D) via ONNX + - wCNN = QualityScore (0.5 - 0.8) + - wLBPH = 1 - wCNN (0.2 - 0.5) + + Features: + - YuNet face detection + - Quality Assessment (Blur, Brightness, Pose) + - Multi-frame enrollment (15 → 5 best frames) + - Incremental Template Update (sim >= 0.98) + - Top-K results + """ + + _instance = None + _system = None + _is_loaded = False + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def load(self) -> bool: + """Load EnhancedFaceRecognitionSystem.""" + if self._is_loaded: + return True + + try: + # Tambahkan ModelBaru ke sys.path + _ensure_ml_path() + + from enhanced_system import EnhancedFaceRecognitionSystem + + os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2' + + self._system = EnhancedFaceRecognitionSystem() + self._is_loaded = True + + log.info("[ML-Enhanced] EnhancedFaceRecognitionSystem loaded!") + log.info(f"[ML-Enhanced] Gallery: {self._system.get_gallery_size()} identities") + log.info(f"[ML-Enhanced] Threshold: {self._system.calibrator.get_threshold():.4f}") + + return True + + except ImportError as e: + log.error(f"[ML-Enhanced] Import error: {e}") + return False + except Exception as e: + log.error(f"[ML-Enhanced] Load error: {e}") + import traceback + traceback.print_exc() + return False + + def enroll(self, nim: str, frames: list, metadata: dict = None) -> dict: + """Enroll with multi-frame.""" + if not self._is_loaded: + if not self.load(): + return {"success": False, "message": "System not loaded"} + + try: + success = self._system.enroll(nim, frames, metadata) + return { + "success": success, + "message": f"Enrolled: {nim}" if success else "Failed" + } + except Exception as e: + log.error(f"[ML-Enhanced] Enrollment error: {e}") + return {"success": False, "message": str(e)} + + def recognize(self, bgr_image) -> dict: + """Recognize face.""" + if not self._is_loaded: + if not self.load(): + return self._error_response("System not loaded") + + try: + result = self._system.recognize(bgr_image, update_template=True) + + if result.is_recognized: + return { + "is_known": True, + "person_name": result.nim, + "confidence": float(result.similarity), + "cnn_similarity": float(result.cnn_similarity), + "lbph_similarity": float(result.lbph_similarity), + "message": f"Recognized: {result.nim}", + "model_type": "enhanced_score_fusion", + "top_k": [ + {"nim": r['nim'], "score": float(r['fused'])} + for r in (result.top_k_results or [])[:3] + ] + } + else: + return { + "is_known": False, + "person_name": "Unknown", + "confidence": float(result.similarity), + "cnn_similarity": float(result.cnn_similarity), + "lbph_similarity": float(result.lbph_similarity), + "message": f"Unknown (sim={result.similarity:.4f})", + "model_type": "enhanced_score_fusion" + } + except Exception as e: + log.error(f"[ML-Enhanced] Recognition error: {e}") + return self._error_response(str(e)) + + def verify(self, bgr_image, nim: str) -> tuple: + """Verify against specific NIM.""" + if not self._is_loaded: + if not self.load(): + return False, 0.0 + + try: + return self._system.verify(bgr_image, nim) + except Exception as e: + log.error(f"[ML-Enhanced] Verify error: {e}") + return False, 0.0 + + def get_gallery_size(self) -> int: + return self._system.get_gallery_size() if self._is_loaded else 0 + + def list_identities(self) -> list: + return self._system.list_identities() if self._is_loaded else [] + + def health(self) -> dict: + if not self._is_loaded: + return { + "model_loaded": False, + "model_type": "enhanced_score_fusion", + "architecture": "Score-Level Fusion" + } + + return { + "model_loaded": True, + "model_type": "enhanced_score_fusion", + "model_name": "EnhancedFaceRecognitionSystem", + "architecture": "Score-Level Fusion (LBPH + MobileFaceNet)", + "gallery_size": self._system.get_gallery_size(), + "threshold": float(self._system.calibrator.get_threshold()), + "features": [ + "YuNet face detection", + "Quality Assessment (Blur, Brightness, Pose)", + "LBPH (640-D) + MobileFaceNet (512-D)", + "Score-Level Fusion with Quality-Aware Weights", + "Multi-frame enrollment (15 → 5)", + "Incremental Template Update" + ] + } + + def _error_response(self, message: str) -> dict: + return { + "is_known": False, + "person_name": "Unknown", + "confidence": 0.0, + "cnn_similarity": 0.0, + "lbph_similarity": 0.0, + "message": message, + "model_type": "enhanced_score_fusion" + } + + +# ============================================================ +# UNIFIED MODEL MANAGER +# ============================================================ + +class FaceRecognitionManager: + """ + Unified manager untuk face recognition models. + + Mendukung tiga model: + - Classification model (softmax) - legacy + - ArcFace model (embedding + FAISS) - hybrid + - Enhanced model (Score-Level Fusion) - ModelBaru + + Usage: + # Get current model + manager = get_model_manager() + result = manager.recognize(image) + + # Switch model + manager.set_model_type("enhanced") + result = manager.recognize(image) + """ + + def __init__(self): + self._current_type = _get_model_type() + self._classification_wrapper = None + self._arcface_wrapper = None + self._enhanced_wrapper = None + + log.info(f"[FaceRecognitionManager] Active model type: {self._current_type}") + + def set_model_type(self, model_type: str) -> bool: + """ + Switch model type. + + Args: + model_type: "classification", "arcface", atau "enhanced" + + Returns: + True if successful + """ + model_type = model_type.lower() + + if model_type not in ["classification", "arcface", "enhanced"]: + log.error(f"[FaceRecognitionManager] Invalid model type: {model_type}") + return False + + if model_type == self._current_type: + log.info(f"[FaceRecognitionManager] Model type unchanged: {model_type}") + return True + + log.info(f"[FaceRecognitionManager] Switching model: {self._current_type} -> {model_type}") + self._current_type = model_type + return True + + def get_model_type(self) -> str: + """Get current model type.""" + return self._current_type + + def recognize(self, bgr_image) -> Dict[str, Any]: + """ + Recognize wajah menggunakan model yang aktif. + + Returns: + Dict dengan recognition result + """ + if self._current_type == "enhanced": + if self._enhanced_wrapper is None: + self._enhanced_wrapper = EnhancedFaceRecognitionWrapper() + return self._enhanced_wrapper.recognize(bgr_image) + elif self._current_type == "arcface": + if self._arcface_wrapper is None: + self._arcface_wrapper = ArcFaceRecognitionWrapper() + return self._arcface_wrapper.recognize(bgr_image) + else: + if self._classification_wrapper is None: + self._classification_wrapper = HybridFaceRecognitionWrapper() + return self._classification_wrapper.recognize(bgr_image) + + def enroll(self, person_id: str, person_name: str, bgr_image) -> Dict[str, Any]: + """ + Enroll new person (ArcFace and Enhanced models only). + + Returns: + Dict dengan enrollment result + """ + if self._current_type == "enhanced": + if self._enhanced_wrapper is None: + self._enhanced_wrapper = EnhancedFaceRecognitionWrapper() + return self._enhanced_wrapper.enroll(person_id, [bgr_image], {"person_name": person_name}) + elif self._current_type == "arcface": + if self._arcface_wrapper is None: + self._arcface_wrapper = ArcFaceRecognitionWrapper() + return self._arcface_wrapper.enroll(person_id, person_name, bgr_image) + else: + return { + "success": False, + "message": "Enrollment only supported in ArcFace/Enhanced mode" + } + + def health(self) -> Dict[str, Any]: + """Get health status of active model.""" + if self._current_type == "enhanced": + if self._enhanced_wrapper is None: + self._enhanced_wrapper = EnhancedFaceRecognitionWrapper() + return self._enhanced_wrapper.health() + elif self._current_type == "arcface": + if self._arcface_wrapper is None: + self._arcface_wrapper = ArcFaceRecognitionWrapper() + return self._arcface_wrapper.health() + else: + if self._classification_wrapper is None: + self._classification_wrapper = HybridFaceRecognitionWrapper() + return self._classification_wrapper.health() + + def preload(self) -> bool: + """Preload the active model.""" + log.info(f"[FaceRecognitionManager] Preloading model: {self._current_type}") + + if self._current_type == "enhanced": + if self._enhanced_wrapper is None: + self._enhanced_wrapper = EnhancedFaceRecognitionWrapper() + return self._enhanced_wrapper.load() + elif self._current_type == "arcface": + if self._arcface_wrapper is None: + self._arcface_wrapper = ArcFaceRecognitionWrapper() + return self._arcface_wrapper.load() + else: + if self._classification_wrapper is None: + self._classification_wrapper = HybridFaceRecognitionWrapper() + return self._classification_wrapper.load() + + +# ============================================================ +# GLOBAL FUNCTIONS +# ============================================================ + +_manager = None + + +def get_model_manager() -> FaceRecognitionManager: + """Get singleton FaceRecognitionManager.""" + global _manager + if _manager is None: + _ensure_ml_path() + _manager = FaceRecognitionManager() + return _manager + + +def get_ml_model(): + """ + Get ML model (legacy compatibility). + + Returns: + FaceRecognitionManager instance + """ + return get_model_manager() + + +def preload_ml_model() -> bool: + """Pre-load model saat startup backend.""" + try: + manager = get_model_manager() + return manager.preload() + except Exception as e: + log.warning(f"[ML] Pre-load failed: {e}") + import traceback + traceback.print_exc() + return False + + +def is_ml_available() -> bool: + """Check if ML model is available.""" + global _manager + if _manager is None: + return False + + try: + model_manager = get_model_manager() + return model_manager.health().get("model_loaded", False) + except: + return False + + +def switch_model(model_type: str) -> bool: + """ + Switch to a different model type. + + Args: + model_type: "classification", "arcface", atau "enhanced" + + Returns: + True if successful + """ + manager = get_model_manager() + return manager.set_model_type(model_type) + + +def get_current_model_type() -> str: + """Get current model type.""" + manager = get_model_manager() + return manager.get_model_type() + + +def enroll_person(person_id: str, person_name: str, bgr_image) -> Dict[str, Any]: + """ + Enroll new person (ArcFace only). + + Args: + person_id: Unique identifier + person_name: Display name + bgr_image: BGR image + + Returns: + Dict dengan enrollment result + """ + manager = get_model_manager() + return manager.enroll(person_id, person_name, bgr_image) diff --git a/facerecognition_presensi/backend/app/services/training_service.py b/facerecognition_presensi/backend/app/services/training_service.py new file mode 100644 index 0000000..e69de29 diff --git a/facerecognition_presensi/backend/app/utils/dependencies.py b/facerecognition_presensi/backend/app/utils/dependencies.py new file mode 100644 index 0000000..3537dfb --- /dev/null +++ b/facerecognition_presensi/backend/app/utils/dependencies.py @@ -0,0 +1,46 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from app.config.database import get_db +from app.utils.security import decode_access_token +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +def get_current_user( + token: str = Depends(oauth2_scheme), + db: Session = Depends(get_db), +) -> User: + exc = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token tidak valid atau kadaluarsa", + headers={"WWW-Authenticate": "Bearer"}, + ) + payload = decode_access_token(token) + if payload is None: + raise exc + user_id = payload.get("sub") + if user_id is None: + raise exc + user = db.query(User).filter(User.id == int(user_id)).first() + if user is None: + raise exc + return user + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + if current_user.role.value not in ("admin", "dosen"): + raise HTTPException(status_code=403, detail="Akses Admin diperlukan") + return current_user + + +def require_dosen(current_user: User = Depends(get_current_user)) -> User: + if current_user.role.value not in ("admin", "dosen"): + raise HTTPException( + status_code=403, detail="Akses Dosen atau Admin diperlukan") + return current_user + + +def require_mahasiswa(current_user: User = Depends(get_current_user)) -> User: + return current_user # semua role bisa diff --git a/facerecognition_presensi/backend/app/utils/helpers.py b/facerecognition_presensi/backend/app/utils/helpers.py new file mode 100644 index 0000000..f41867e --- /dev/null +++ b/facerecognition_presensi/backend/app/utils/helpers.py @@ -0,0 +1,35 @@ +import os +import shutil +from fastapi import UploadFile +from app.config.settings import settings + + +def save_upload_file(file: UploadFile, subfolder: str, prefix: str = "") -> str: + """Simpan file upload ke disk, kembalikan path relatif.""" + save_dir = os.path.join(settings.UPLOAD_DIR, subfolder) + os.makedirs(save_dir, exist_ok=True) + filename = f"{prefix}_{file.filename}" if prefix else file.filename + file_path = os.path.join(save_dir, filename) + with open(file_path, "wb") as f: + shutil.copyfileobj(file.file, f) + return file_path + + +def save_dataset_file(file: UploadFile, mahasiswa_id: int, nim: str, count: int) -> str: + """Simpan foto dataset wajah mahasiswa.""" + save_dir = os.path.join(settings.DATASET_DIR, str(mahasiswa_id)) + os.makedirs(save_dir, exist_ok=True) + ext = os.path.splitext(file.filename or "foto.jpg")[1] or ".jpg" + filename = f"{nim}_{count}{ext}" + file_path = os.path.join(save_dir, filename) + with open(file_path, "wb") as f: + shutil.copyfileobj(file.file, f) + return file_path + + +def delete_file_if_exists(path: str) -> bool: + """Hapus file jika ada.""" + if path and os.path.exists(path): + os.remove(path) + return True + return False diff --git a/facerecognition_presensi/backend/app/utils/response.py b/facerecognition_presensi/backend/app/utils/response.py new file mode 100644 index 0000000..5ee8189 --- /dev/null +++ b/facerecognition_presensi/backend/app/utils/response.py @@ -0,0 +1,34 @@ +from fastapi.responses import JSONResponse +from typing import Any + + +def success_response(data: Any = None, message: str = "Berhasil", status_code: int = 200) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={ + "success": True, + "message": message, + "data": data, + }, + ) + + +def error_response(message: str = "Terjadi kesalahan", status_code: int = 400) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={ + "success": False, + "message": message, + "data": None, + }, + ) + + +def paginated_response(data: list, total: int, skip: int, limit: int) -> dict: + return { + "success": True, + "total": total, + "skip": skip, + "limit": limit, + "data": data, + } diff --git a/facerecognition_presensi/backend/app/utils/security.py b/facerecognition_presensi/backend/app/utils/security.py new file mode 100644 index 0000000..681b655 --- /dev/null +++ b/facerecognition_presensi/backend/app/utils/security.py @@ -0,0 +1,30 @@ +from passlib.context import CryptContext +from jose import JWTError, jwt +from datetime import datetime, timedelta, timezone +from typing import Any +from app.config.settings import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(data: dict[str, Any]) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def decode_access_token(token: str) -> dict | None: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None diff --git a/facerecognition_presensi/backend/init_db.py b/facerecognition_presensi/backend/init_db.py new file mode 100644 index 0000000..3bc5f01 --- /dev/null +++ b/facerecognition_presensi/backend/init_db.py @@ -0,0 +1,9 @@ +"""Create missing tables for a NEW database; never drops existing tables.""" +import app.models # noqa: F401 +from app.config.database import Base, engine + +if __name__ == "__main__": + Base.metadata.create_all(bind=engine) + print("Schema siap. Tabel yang sudah ada tidak diubah:") + for name in sorted(Base.metadata.tables): + print(" -", name) diff --git a/facerecognition_presensi/backend/migrate.py b/facerecognition_presensi/backend/migrate.py new file mode 100644 index 0000000..728dc2b --- /dev/null +++ b/facerecognition_presensi/backend/migrate.py @@ -0,0 +1,32 @@ +""" +Migration script: drop all existing tables dan recreate dengan schema baru. +Jalankan SEKALI sebelum menjalankan backend server. +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.config.database import engine, Base + +# Import semua models agar Base.metadata mengetahui seluruh tabel +import app.models # noqa: F401 + +def migrate(): + print("Menghapus semua tabel lama...") + Base.metadata.drop_all(bind=engine) + print("Semua tabel dihapus.") + + print("Membuat ulang tabel dengan schema baru...") + Base.metadata.create_all(bind=engine) + print("Semua tabel berhasil dibuat ulang!") + print() + print("Tabel yang dibuat:") + for table in Base.metadata.sorted_tables: + print(f" - {table.name}") + +if __name__ == "__main__": + confirm = input("\nPERINGATAN: Semua data akan TERHAPUS! Ketik 'ya' untuk lanjut: ") + if confirm.strip().lower() == "ya": + migrate() + else: + print("Dibatalkan.") diff --git a/facerecognition_presensi/backend/migrate_fk_cascade.py b/facerecognition_presensi/backend/migrate_fk_cascade.py new file mode 100644 index 0000000..23112b1 --- /dev/null +++ b/facerecognition_presensi/backend/migrate_fk_cascade.py @@ -0,0 +1,91 @@ +""" +Migration script: Fix FK constraints to use CASCADE for proper cascading deletes. + +Run: python migrate_fk_cascade.py + +This script changes ondelete behavior: +- matakuliah -> penawaran_matakuliah: RESTRICT -> CASCADE +- penawaran_matakuliah -> jadwal: RESTRICT -> CASCADE +- jadwal -> pertemuan: RESTRICT -> CASCADE +""" + +import pymysql +from app.config.settings import settings + +def get_connection(): + return pymysql.connect( + host=settings.DB_HOST, + port=settings.DB_PORT, + user=settings.DB_USER, + password=settings.DB_PASSWORD, + database=settings.DB_NAME, + charset="utf8mb4", + ) + +def get_fk_name(cursor, table, column, ref_table): + """Get the FK constraint name for a given column/table combination.""" + cursor.execute(""" + SELECT CONSTRAINT_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME = %s + AND COLUMN_NAME = %s + AND REFERENCED_TABLE_NAME = %s + """, (settings.DB_NAME, table, column, ref_table)) + row = cursor.fetchone() + return row[0] if row else None + +def fix_fk(cursor, table, column, ref_table, ref_column): + fk_name = get_fk_name(cursor, table, column, ref_table) + if not fk_name: + print(f" [SKIP] No FK found for {table}.{column} -> {ref_table}.{ref_column}") + return + + print(f" [FIX] {table}.{column} -> {ref_table}.{ref_column} (FK: {fk_name})") + # Drop existing FK + cursor.execute(f"ALTER TABLE `{table}` DROP FOREIGN KEY `{fk_name}`") + # Add new FK with CASCADE + cursor.execute(f""" + ALTER TABLE `{table}` + ADD CONSTRAINT `{fk_name}` + FOREIGN KEY (`{column}`) + REFERENCES `{ref_table}` (`{ref_column}`) + ON DELETE CASCADE ON UPDATE RESTRICT + """) + print(f" [OK] {table}.{column} -> {ref_table}.{ref_column} now CASCADE") + +def main(): + print("=== FK Cascade Migration ===\n") + conn = get_connection() + try: + cursor = conn.cursor() + + # Disable FK checks temporarily + cursor.execute("SET FOREIGN_KEY_CHECKS = 0") + + # 1. penawaran_matakuliah.id_matakuliah -> matakuliah.id_matakuliah + print("1. Fixing penawaran_matakuliah -> matakuliah") + fix_fk(cursor, "penawaran_matakuliah", "id_matakuliah", "matakuliah", "id_matakuliah") + + # 2. jadwal.id_penawaran -> penawaran_matakuliah.id_penawaran + print("\n2. Fixing jadwal -> penawaran_matakuliah") + fix_fk(cursor, "jadwal", "id_penawaran", "penawaran_matakuliah", "id_penawaran") + + # 3. pertemuan.id_jadwal -> jadwal.id_jadwal + print("\n3. Fixing pertemuan -> jadwal") + fix_fk(cursor, "pertemuan", "id_jadwal", "jadwal", "id_jadwal") + + # Re-enable FK checks + cursor.execute("SET FOREIGN_KEY_CHECKS = 1") + + conn.commit() + print("\n=== Migration completed successfully! ===") + except Exception as e: + conn.rollback() + print(f"\n[ERROR] Migration failed: {e}") + raise + finally: + conn.close() + +if __name__ == "__main__": + main() diff --git a/facerecognition_presensi/backend/migrate_kelas_mahasiswa.py b/facerecognition_presensi/backend/migrate_kelas_mahasiswa.py new file mode 100644 index 0000000..3d78ab5 --- /dev/null +++ b/facerecognition_presensi/backend/migrate_kelas_mahasiswa.py @@ -0,0 +1,41 @@ +""" +Migration: Tambah kolom id_kelas ke tabel mahasiswa +Jalankan sekali: python migrate_kelas_mahasiswa.py +""" +import sys +import os + +sys.path.insert(0, os.path.dirname(__file__)) + +from app.config.database import engine +from sqlalchemy import text + + +def migrate(): + with engine.connect() as conn: + # Cek apakah kolom sudah ada + result = conn.execute(text(""" + SELECT COUNT(*) as cnt + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mahasiswa' + AND COLUMN_NAME = 'id_kelas' + """)) + row = result.fetchone() + if row[0] > 0: + print("✓ Kolom id_kelas sudah ada di tabel mahasiswa. Skip.") + return + + # Tambah kolom id_kelas + conn.execute(text(""" + ALTER TABLE mahasiswa + ADD COLUMN id_kelas BIGINT NULL AFTER id_golongan, + ADD CONSTRAINT fk_mahasiswa_kelas + FOREIGN KEY (id_kelas) REFERENCES kelas(id) ON DELETE SET NULL + """)) + conn.commit() + print("✓ Kolom id_kelas berhasil ditambahkan ke tabel mahasiswa!") + + +if __name__ == "__main__": + migrate() diff --git a/facerecognition_presensi/backend/migrate_pertemuan.py b/facerecognition_presensi/backend/migrate_pertemuan.py new file mode 100644 index 0000000..5e91d26 --- /dev/null +++ b/facerecognition_presensi/backend/migrate_pertemuan.py @@ -0,0 +1,78 @@ +""" +Migration script: Add id_geolokasi and mode_lokasi columns to pertemuan table. +Run once from the backend directory: python migrate_pertemuan.py +""" +import sys +sys.path.insert(0, ".") + +from app.config.database import engine +from sqlalchemy import text + + +def migrate(): + with engine.connect() as conn: + # Step 1: Check if geolokasi table exists; create it if not + result = conn.execute(text("SHOW TABLES LIKE 'geolokasi'")) + geolokasi_exists = len(result.fetchall()) > 0 + print(f"geolokasi table exists: {geolokasi_exists}") + + if not geolokasi_exists: + print("Creating geolokasi table...") + conn.execute(text(""" + CREATE TABLE geolokasi ( + id_geolokasi BIGINT AUTO_INCREMENT PRIMARY KEY, + nama_lokasi VARCHAR(255) NOT NULL, + latitude DOUBLE NOT NULL, + longitude DOUBLE NOT NULL, + radius_meter INT NOT NULL DEFAULT 100, + status ENUM('aktif','nonaktif') NOT NULL DEFAULT 'aktif', + keterangan TEXT NULL + ) + """)) + conn.commit() + print("geolokasi table created.") + + # Step 2: Check current columns in pertemuan + result = conn.execute(text("DESCRIBE pertemuan")) + cols = {row[0] for row in result.fetchall()} + print(f"Current pertemuan columns: {cols}") + + # Step 3: Add id_geolokasi if missing + if "id_geolokasi" not in cols: + print("Adding id_geolokasi column...") + conn.execute(text(""" + ALTER TABLE pertemuan + ADD COLUMN id_geolokasi BIGINT NULL, + ADD CONSTRAINT fk_pertemuan_geolokasi + FOREIGN KEY (id_geolokasi) + REFERENCES geolokasi(id_geolokasi) + ON DELETE SET NULL + """)) + conn.commit() + print("id_geolokasi added.") + else: + print("id_geolokasi already exists, skipping.") + + # Step 4: Add mode_lokasi if missing + if "mode_lokasi" not in cols: + print("Adding mode_lokasi column...") + conn.execute(text(""" + ALTER TABLE pertemuan + ADD COLUMN mode_lokasi ENUM('bebas','wajib') NOT NULL DEFAULT 'bebas' + """)) + conn.commit() + print("mode_lokasi added.") + else: + print("mode_lokasi already exists, skipping.") + + # Verify final state + result = conn.execute(text("DESCRIBE pertemuan")) + print("\nFinal pertemuan columns:") + for row in result.fetchall(): + print(" ", row) + + print("\nMigration complete!") + + +if __name__ == "__main__": + migrate() diff --git a/facerecognition_presensi/backend/requirements.txt b/facerecognition_presensi/backend/requirements.txt new file mode 100644 index 0000000..29f68e7 --- /dev/null +++ b/facerecognition_presensi/backend/requirements.txt @@ -0,0 +1,33 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +sqlalchemy==2.0.30 +pymysql==1.1.1 +cryptography==42.0.7 +python-dotenv==1.0.1 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-jose[cryptography]==3.3.0 +python-multipart==0.0.9 +pydantic[email]==2.7.1 +pydantic-settings==2.2.1 +Pillow==10.3.0 +numpy==1.26.4 +email-validator==2.1.1 +httpx==0.27.0 + +# ── ML / Face Recognition dependencies ─────────────────────────────────────── +opencv-contrib-python==4.8.1.78 +tensorflow==2.15.0 +faiss-cpu>=1.7.4 +mtcnn>=0.1.1 +retina-face>=0.0.17 +scipy>=1.11.0 +scikit-learn==1.3.0 + +# ── Anti-Spoofing (SilentFace - MiniFASNet) ─────────────────────────────────── +# Install dengan: pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu +torch>=2.0.0 +torchvision>=0.15.0 + +# MobileFaceNet ONNX inference +onnxruntime==1.17.3 diff --git a/facerecognition_presensi/backend/seed.py b/facerecognition_presensi/backend/seed.py new file mode 100644 index 0000000..deee7e4 --- /dev/null +++ b/facerecognition_presensi/backend/seed.py @@ -0,0 +1,28 @@ +"""Create an administrator interactively, without a shared default password.""" +from getpass import getpass +from app.config.database import SessionLocal +from app.models.user import User, UserRoleEnum +from app.utils.security import get_password_hash + + +def seed(): + name = input("Nama admin: ").strip() + email = input("Email admin: ").strip() + if not name or "@" not in email: + raise SystemExit("Nama dan email valid wajib diisi.") + password = getpass("Password admin (12-72 byte UTF-8): ") + if not 12 <= len(password.encode("utf-8")) <= 72: + raise SystemExit("Panjang password harus 12-72 byte UTF-8.") + if password != getpass("Ulangi password: "): + raise SystemExit("Password tidak cocok.") + with SessionLocal() as db: + if db.query(User).filter(User.email == email).first(): + raise SystemExit("Email sudah terdaftar; akun tidak diubah.") + db.add(User(name=name, email=email, password=get_password_hash(password), + role=UserRoleEnum.admin)) + db.commit() + print("Admin berhasil dibuat.") + + +if __name__ == "__main__": + seed()