diff --git a/api/bootstrap.php b/api/bootstrap.php index 1b8268f..d0147d4 100644 --- a/api/bootstrap.php +++ b/api/bootstrap.php @@ -30,4 +30,10 @@ function env(string $key): mixed { defined('YII_DEBUG') or define('YII_DEBUG', filter_var(env('YII_DEBUG') ?? false, FILTER_VALIDATE_BOOLEAN)); defined('YII_ENV') or define('YII_ENV', env('YII_ENV') ?? 'prod'); +if (YII_ENV === 'dev') { + ini_set('display_errors', '1'); + ini_set('display_startup_errors', '1'); + error_reporting(E_ALL); +} + require __DIR__ . '/vendor/yiisoft/yii2/Yii.php'; diff --git a/api/commands/PokemonImportController.php b/api/commands/PokemonImportController.php index 9decd53..10e5d1b 100644 --- a/api/commands/PokemonImportController.php +++ b/api/commands/PokemonImportController.php @@ -46,8 +46,13 @@ public function actionFetch(): int foreach ($data['results'] as $pokemon) { try { $species = new PokemonSpecies(); + $url = $pokemon['url']; + $imageId = basename($url); + $species->name = $pokemon['name']; - $species->url = $pokemon['url']; + $species->url = $url; + $species->image = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/' . $imageId . '.svg'; + if (!$species->save()) { $this->stderr($pokemon['name'] . " (FAILED): " . json_encode($species->errors, JSON_THROW_ON_ERROR) . "\n"); } else { diff --git a/api/config/web.php b/api/config/web.php index bf5942d..9aac4ce 100644 --- a/api/config/web.php +++ b/api/config/web.php @@ -1,5 +1,7 @@ '@vendor/npm-asset', ], 'components' => [ + 'response' => [ + 'format' => \yii\web\Response::FORMAT_JSON, + ], 'request' => [ // !!! insert a secret key in the following (if it is empty) - this is required by cookie validation 'cookieValidationKey' => 'my-secret-key', + 'enableCsrfCookie' => false, + 'enableCsrfValidation' => false, + 'parsers' => ['application/json' => JsonParser::class], ], 'cache' => [ 'class' => FileCache::class, ], 'user' => [ 'identityClass' => User::class, - 'enableAutoLogin' => true, + 'loginUrl' => null, ], 'mailer' => [ 'class' => \yii\symfonymailer\Mailer::class, @@ -40,8 +48,19 @@ 'targets' => [ [ 'class' => FileTarget::class, + 'logFile' => '@runtime/logs/error.log', 'levels' => ['error', 'warning'], ], + [ + 'class' => FileTarget::class, + 'logFile' => '@runtime/logs/info.log', + 'levels' => ['info'], + ], + [ + 'class' => FileTarget::class, + 'logFile' => '@runtime/logs/debug.log', + 'levels' => ['trace'], + ], ], ], 'db' => $db, diff --git a/api/controllers/AuthController.php b/api/controllers/AuthController.php new file mode 100644 index 0000000..952b582 --- /dev/null +++ b/api/controllers/AuthController.php @@ -0,0 +1,88 @@ +load(Yii::$app->request->getBodyParams(), ''); + + if (!$isCorrectBody) + { + throw new BadRequestHttpException('Request body is malformed'); + } + + $isValid = $loginForm->validate(); + + if (!$isValid) + { + return $loginForm; + } + + $user = User::find()->where([ + 'username' => $loginForm->username + ])->one(); + + if (!$user) + { + throw new ServerErrorHttpException('User no longer exists'); + } + + $isLoggedIn = Yii::$app->user->login($user); + + if (!$isLoggedIn) + { + throw new ServerErrorHttpException('Failed to login user'); + } + + return null; + } + + public function behaviors(): array + { + return ArrayHelper::merge(parent::behaviors(), [ + 'authenticator' => [ + 'except' => ['login'] + ] + ]); + } + + /** + * Destroys a user session, and logs the user out of the application. + * + * @return null + * @throws ServerErrorHttpException Failed to log out user + */ + public function actionLogout(): null + { + $isLoggedOut = Yii::$app->user->logout(); + if (!$isLoggedOut) + { + throw new ServerErrorHttpException('Failed to log out user'); + } + return null; + } +} diff --git a/api/controllers/BoxesController.php b/api/controllers/BoxesController.php index e316a09..4cb5fda 100644 --- a/api/controllers/BoxesController.php +++ b/api/controllers/BoxesController.php @@ -18,4 +18,15 @@ class BoxesController extends ActiveController * @var string The model class associated with this controller. */ public $modelClass = Box::class; + + /** + * Returns the total number of boxes belonging to this user + * + * @param int $userId + * @return int + */ + public function actionUsersCount(int $userId): int + { + return $this->modelClass::find()->where(['user_id' => $userId])->count(); + } } diff --git a/api/controllers/CategoriesController.php b/api/controllers/CategoriesController.php index e1767d4..d42dd0b 100644 --- a/api/controllers/CategoriesController.php +++ b/api/controllers/CategoriesController.php @@ -3,6 +3,7 @@ namespace app\controllers; use app\models\Category; +use app\models\Team; use app\rest\ActiveController; /** @@ -18,4 +19,38 @@ class CategoriesController extends ActiveController * @var string The model class associated with this controller. */ public $modelClass = Category::class; + public string $teamClass = Team::class; + + /** + * Returns the total number of categories belonging to this user + * + * @param int $userId + * @return int + */ + public function actionUsersCount(int $userId): int // todo: move this to UsersController + { + return $this->modelClass::find()->where(['user_id' => $userId])->count(); + } + + /** + * Returns the total number of teams in this category + * + * @param int $categoryId + * @return int + */ + public function actionTeamsCount(int $categoryId): int + { + return $this->teamClass::find()->where(['category_id' => $categoryId])->count(); + } + + /** + * Returns the teams in this category + * + * @param int $categoryId + * @return array + */ + public function actionTeamsIndex(int $categoryId): array + { + return $this->teamClass::find()->where(['category_id' => $categoryId])->all(); + } } diff --git a/api/controllers/HealthController.php b/api/controllers/HealthController.php index 72bf4e0..ee8d098 100644 --- a/api/controllers/HealthController.php +++ b/api/controllers/HealthController.php @@ -3,8 +3,10 @@ namespace app\controllers; use Yii; +use yii\filters\AccessControl; use yii\filters\Cors; use app\rest\Controller; +use yii\helpers\ArrayHelper; use yii\web\Response; /** @@ -14,6 +16,22 @@ */ class HealthController extends Controller { + // todo: make a second version that doesnt require login + public function behaviors(): array + { + return ArrayHelper::merge(parent::behaviors(), [ + 'access' => [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'roles' => ['@'], + 'allow' => true, + ], + ], + ], + ]); + } + /** * Simple server health check. * diff --git a/api/controllers/PokemonInstancesController.php b/api/controllers/PokemonInstancesController.php index bd1cfdf..3aeb725 100644 --- a/api/controllers/PokemonInstancesController.php +++ b/api/controllers/PokemonInstancesController.php @@ -3,7 +3,7 @@ namespace app\controllers; use app\models\PokemonInstance; -use yii\rest\ActiveController; +use app\rest\ActiveController; /** * Class PokemonInstancesController @@ -12,7 +12,7 @@ * * @see PokemonInstance */ -class PokemonInstancesController extends activeController +class PokemonInstancesController extends ActiveController { /** * @var string The model class associated with this controller. diff --git a/api/controllers/PokemonSpeciesController.php b/api/controllers/PokemonSpeciesController.php index 7022944..25b3161 100644 --- a/api/controllers/PokemonSpeciesController.php +++ b/api/controllers/PokemonSpeciesController.php @@ -3,7 +3,8 @@ namespace app\controllers; use app\models\PokemonSpecies; -use yii\rest\ActiveController; +use app\rest\ActiveController; +use yii\helpers\ArrayHelper; /** * Class PokemonSpeciesController @@ -18,4 +19,23 @@ class PokemonSpeciesController extends ActiveController * @var string The model class associated with this controller. */ public $modelClass = PokemonSpecies::class; + + /** + * {@inheritdoc} + */ + public function actions(): array + { + return ArrayHelper::merge(parent::actions(), [ + 'index' => [ + 'pagination' => [ + 'pageSizeLimit' => [1,10000], + ] + ] + ]); + } + + public function actionCount(): int + { + return $this->modelClass::find()->count(); + } } diff --git a/api/controllers/TeamsController.php b/api/controllers/TeamsController.php index 1cddb8b..871a171 100644 --- a/api/controllers/TeamsController.php +++ b/api/controllers/TeamsController.php @@ -2,9 +2,12 @@ namespace app\controllers; +use app\models\PokemonInstance; +use app\models\PokemonSpecies; use app\models\Team; use app\rest\ActiveController; use Yii; +use yii\db\ActiveQuery; /** * Class TeamsController @@ -19,4 +22,41 @@ class TeamsController extends ActiveController * @var string The model class associated with this controller. */ public $modelClass = Team::class; + public string $pokemonInstanceClass = PokemonInstance::class; + public string $pokemonSpeciesClass = PokemonSpecies::class; + + /** + * Returns the total number of teams belonging to a user + * + * @param int $userId + * @return int + */ + public function actionUsersCount(int $userId): int // todo: move this to UsersController + { + return $this->modelClass::find()->where(['user_id' => $userId])->count(); + } + + /** + * Returns the teams within a category + * + * @param int $categoryId + * @return array + */ + public function actionCategoryView(int $categoryId): array + { + return $this->modelClass::find()->where(['category_id' => $categoryId])->all(); + } + + /** + * Returns the pokemon instances + species within a team + * + * @param int $teamId + * @return array + */ + public function actionPokemonIndex(int $teamId): array // todo: fix me + { + $instances = $this->pokemonInstanceClass::find()->where(['team_id' => $teamId])->all(); + $species = array_map(fn($instance) => $this->pokemonSpeciesClass::find()->where(['id' => $instance->pokemon_species_id])->one(), $instances); + return ['instances' => $instances, 'species' => $species]; + } } diff --git a/api/controllers/UsersController.php b/api/controllers/UsersController.php index 70957c7..cf716fb 100644 --- a/api/controllers/UsersController.php +++ b/api/controllers/UsersController.php @@ -2,6 +2,7 @@ namespace app\controllers; +use app\models\forms\SignupForm; use app\rest\ActiveController; use app\models\User; @@ -18,4 +19,13 @@ class UsersController extends ActiveController * @var string The model class associated with this controller. */ public $modelClass = User::class; + + public function actions(): array + { + $actions = parent::actions(); + + $actions['create']['modelClass'] = SignupForm::class; + + return $actions; + } } diff --git a/api/db/ActiveRecord.php b/api/db/ActiveRecord.php new file mode 100644 index 0000000..37a3466 --- /dev/null +++ b/api/db/ActiveRecord.php @@ -0,0 +1,15 @@ +user->isGuest; + if (!$isGuest) + { + return Yii::$app->user->identity; + } + return null; + } +} diff --git a/api/interfaces/ActiveRecordInterface.php b/api/interfaces/ActiveRecordInterface.php new file mode 100644 index 0000000..a825893 --- /dev/null +++ b/api/interfaces/ActiveRecordInterface.php @@ -0,0 +1,12 @@ + beginPage() ?> - + <?= Html::encode($this->title) ?> diff --git a/api/migrations/m251014_155652_create_table_images_collections.php b/api/migrations/m251014_155652_create_table_images_collections.php deleted file mode 100644 index 6041d0e..0000000 --- a/api/migrations/m251014_155652_create_table_images_collections.php +++ /dev/null @@ -1,34 +0,0 @@ -createTable('{{%images_collections}}', [ - 'id' => $this->primaryKey(), - - 'alt' => $this->string(255)->notNull()->defaultValue('Image Not Found'), - 'image16' => $this->text(), - 'image32' => $this->text(), - 'image64' => $this->text(), - ]); - } - - /** - * {@inheritdoc} - */ - public function safeDown(): void - { - $this->dropTable('{{%images_collections}}'); - } -} diff --git a/api/migrations/m251016_111854_create_table_formats.php b/api/migrations/m251016_111854_create_table_formats.php index a41ca73..a4c971a 100644 --- a/api/migrations/m251016_111854_create_table_formats.php +++ b/api/migrations/m251016_111854_create_table_formats.php @@ -21,6 +21,8 @@ public function safeUp(): void 'abbreviation' => $this->string(), 'max_generation' => $this->integer(), 'min_generation' => $this->integer()->notNull(), + 'created_at' => $this->bigInteger()->notNull(), + 'updated_at' => $this->bigInteger()->notNull(), ]); } diff --git a/api/migrations/m251020_090412_create_table_categories.php b/api/migrations/m251020_090412_create_table_categories.php index 9beb6bd..711d2b1 100644 --- a/api/migrations/m251020_090412_create_table_categories.php +++ b/api/migrations/m251020_090412_create_table_categories.php @@ -16,10 +16,11 @@ public function safeUp(): void { $this->createTable('{{%categories}}', [ 'id' => $this->primaryKey(), - 'user_id' => $this->integer()->notNull(), + 'user_id' => $this->integer(), 'name' => $this->string(255)->notNull(), - 'created_at' => $this->dateTime()->notNull(), + 'created_at' => $this->bigInteger()->notNull(), + 'updated_at' => $this->bigInteger()->notNull(), ]); /** diff --git a/api/migrations/m251020_091751_create_table_boxes.php b/api/migrations/m251020_091751_create_table_boxes.php index f3cd252..7644a72 100644 --- a/api/migrations/m251020_091751_create_table_boxes.php +++ b/api/migrations/m251020_091751_create_table_boxes.php @@ -17,8 +17,11 @@ public function safeUp(): void $this->createTable('{{%boxes}}', [ 'id' => $this->primaryKey(), 'category_id' => $this->integer(), + 'user_id' => $this->integer(), 'name' => $this->string(255)->notNull(), + 'created_at' => $this->bigInteger()->notNull(), + 'updated_at' => $this->bigInteger()->notNull(), ]); /** @@ -33,6 +36,19 @@ public function safeUp(): void 'SET NULL', 'CASCADE' ); + + /** + * user_id FK + */ + $this->addForeignKey( + 'fk-boxes-user_id', + '{{%boxes}}', + 'user_id', + '{{%users}}', + 'id', + 'CASCADE', + 'SET NULL' + ); } /** @@ -41,6 +57,7 @@ public function safeUp(): void public function safeDown(): void { $this->dropForeignKey('fk-boxes-category_id', '{{%boxes}}'); + $this->dropForeignKey('fk-boxes-user_id', '{{%boxes}}'); $this->dropTable('{{%boxes}}'); } } diff --git a/api/migrations/m251020_092814_create_table_teams.php b/api/migrations/m251020_092814_create_table_teams.php index 5dd45c9..b1e147f 100644 --- a/api/migrations/m251020_092814_create_table_teams.php +++ b/api/migrations/m251020_092814_create_table_teams.php @@ -17,8 +17,11 @@ public function safeUp(): void $this->createTable('{{%teams}}', [ 'id' => $this->primaryKey(), 'category_id' => $this->integer(), + 'user_id' => $this->integer(), 'name' => $this->string(255)->notNull(), + 'created_at' => $this->bigInteger()->notNull(), + 'updated_at' => $this->bigInteger()->notNull(), ]); /** @@ -26,13 +29,26 @@ public function safeUp(): void */ $this->addForeignKey( 'fk-teams-category_id', - '{{%boxes}}', + '{{%teams}}', 'category_id', '{{%categories}}', 'id', 'SET NULL', 'CASCADE' ); + + /** + * user_id FK + */ + $this->addForeignKey( + 'fk-teams-user_id', + '{{%teams}}', + 'user_id', + '{{%users}}', + 'id', + 'CASCADE', + 'SET NULL' + ); } /** @@ -40,7 +56,8 @@ public function safeUp(): void */ public function safeDown(): void { - $this->dropForeignKey('fk-teams-category_id', '{{%boxes}}'); + $this->dropForeignKey('fk-teams-category_id', '{{%teams}}'); + $this->dropForeignKey('fk-teams-user_id', '{{%teams}}'); $this->dropTable('{{%teams}}'); } } diff --git a/api/migrations/m251020_093000_create_table_pokemon_species.php b/api/migrations/m251020_093000_create_table_pokemon_species.php index 63b86e6..677380b 100644 --- a/api/migrations/m251020_093000_create_table_pokemon_species.php +++ b/api/migrations/m251020_093000_create_table_pokemon_species.php @@ -20,24 +20,13 @@ public function safeUp(): void { $this->createTable('{{%pokemon_species}}', [ 'id' => $this->primaryKey(), - 'images_collection_id' => $this->integer(), + 'image' => $this->string(255)->defaultValue(''), // todo: add a default image 'name' => $this->string(255)->notNull(), 'url' => $this->string(255)->notNull()->unique(), + 'created_at' => $this->bigInteger()->notNull(), + 'updated_at' => $this->bigInteger()->notNull(), ]); - - /** - * images_collection_id FK - */ - $this->addForeignKey( - 'fk-pokemon_species-images_collection_id', - '{{%pokemon_species}}', - 'images_collection_id', - '{{%images_collections}}', - 'id', - 'CASCADE', - 'CASCADE' - ); } /** @@ -45,7 +34,6 @@ public function safeUp(): void */ public function safeDown(): void { - $this->dropForeignKey('fk-pokemon_species-images_collection_id', '{{%pokemon_species}}'); $this->dropTable('{{%pokemon_species}}'); } } diff --git a/api/migrations/m251020_093403_create_table_pokemon_instances.php b/api/migrations/m251020_093403_create_table_pokemon_instances.php index 25c0482..807203a 100644 --- a/api/migrations/m251020_093403_create_table_pokemon_instances.php +++ b/api/migrations/m251020_093403_create_table_pokemon_instances.php @@ -24,6 +24,8 @@ public function safeUp(): void 'format_id' => $this->integer(), 'custom_name' => $this->string(255), + 'created_at' => $this->bigInteger()->notNull(), + 'updated_at' => $this->bigInteger()->notNull(), ]); /** diff --git a/api/migrations/m251111_144426_add_auth_columns_to_users_table.php b/api/migrations/m251111_144426_add_auth_columns_to_users_table.php new file mode 100644 index 0000000..ce579fa --- /dev/null +++ b/api/migrations/m251111_144426_add_auth_columns_to_users_table.php @@ -0,0 +1,41 @@ +addColumn('{{%users}}', 'username', $this->string()->notNull()->unique()); + $this->addColumn('{{%users}}', 'password', $this->string()->notNull()); + $this->addColumn('{{%users}}', 'auth_key', $this->string()->notNull()->unique()); + $this->addColumn('{{%users}}', 'email', $this->string()->unique()); + $this->addColumn('{{%users}}', 'created_at', $this->bigInteger()->notNull()); + $this->addColumn('{{%users}}', 'updated_at', $this->bigInteger()->notNull()); + + $this->createIndex('idx-users-username', '{{%users}}', 'username', true); + $this->createIndex('idx-users-email', '{{%users}}', 'email', true); + } + + /** + * {@inheritdoc} + */ + public function safeDown(): void + { + $this->dropIndex('idx-users-username', '{{%users}}'); + $this->dropIndex('idx-users-email', '{{%users}}'); + + $this->dropColumn('{{%users}}', 'username'); + $this->dropColumn('{{%users}}', 'password'); + $this->dropColumn('{{%users}}', 'auth_key'); + $this->dropColumn('{{%users}}', 'email'); + $this->dropColumn('{{%users}}', 'created_at'); + $this->dropColumn('{{%users}}', 'updated_at'); + } +} diff --git a/api/models/Box.php b/api/models/Box.php index 6d24ce7..afa1c1e 100644 --- a/api/models/Box.php +++ b/api/models/Box.php @@ -2,7 +2,8 @@ namespace app\models; -use yii\db\ActiveRecord; +use app\db\ActiveRecord; +use app\validators\BlankNewItemValidator; /** * Class Box @@ -22,4 +23,24 @@ public static function tableName(): string { return '{{%boxes}}'; } + + /** + * {@inheritdoc} + */ + public static function tableNameSingular(): string + { + return 'box'; + } + + /** + * {@inheritdoc} + */ + public function rules(): array + { + return [ + [['category_id', 'user_id', 'name', 'created_at'], 'safe'], + [['name'], 'trim'], + [['name'], BlankNewItemValidator::class], + ]; + } } diff --git a/api/models/Category.php b/api/models/Category.php index c1081c2..ea2ba8e 100644 --- a/api/models/Category.php +++ b/api/models/Category.php @@ -2,7 +2,7 @@ namespace app\models; -use yii\db\ActiveRecord; +use app\validators\BlankNewItemValidator; /** * Class Category @@ -14,7 +14,7 @@ * @property string $name * @property string $created_at */ -class Category extends ActiveRecord +class Category extends \app\db\ActiveRecord { /** * {@inheritdoc} @@ -23,4 +23,24 @@ public static function tableName(): string { return '{{%categories}}'; } + + /** + * {@inheritdoc} + */ + public static function tableNameSingular(): string + { + return 'category'; + } + + /** + * {@inheritdoc} + */ + public function rules(): array + { + return [ + [['user_id', 'name', 'created_at'], 'safe'], + [['name'], 'trim'], + [['name'], BlankNewItemValidator::class], + ]; + } } diff --git a/api/models/Format.php b/api/models/Format.php index 7483246..d8fdf8d 100644 --- a/api/models/Format.php +++ b/api/models/Format.php @@ -2,7 +2,7 @@ namespace app\models; -use yii\db\ActiveRecord; +use app\db\ActiveRecord; /** * Class Format @@ -24,4 +24,12 @@ public static function tableName(): string { return '{{%formats}}'; } + + /** + * {@inheritdoc} + */ + public static function tableNameSingular(): string + { + return 'format'; + } } diff --git a/api/models/ImagesCollection.php b/api/models/ImagesCollection.php deleted file mode 100644 index 573e05d..0000000 --- a/api/models/ImagesCollection.php +++ /dev/null @@ -1,27 +0,0 @@ - $token]); } /** - * Finds user by username - * - * @param string $username - * @return static|null + * {@inheritdoc} */ - public static function findByUsername($username) + public function behaviors(): array { - foreach (self::$users as $user) { - if (strcasecmp($user['username'], $username) === 0) { - return new static($user); - } - } + return [ + 'timestamp' => ['class' => TimestampBehavior::class], + ]; + } - return null; + /** + * {@inheritdoc} + */ + public function fields(): array + { + return [ + 'id' + ]; } /** * {@inheritdoc} */ - public function getId() + public function rules(): array { - return $this->id; + return [ + [['username', 'email'], 'trim'], + [['email', 'username'], EmptyStringValidator::class], + 'passwordRequired' => [['!password'], 'required'], + [['username', '!auth_key'], 'required'], + [['username', 'email', '!password', '!auth_key'], 'string', 'max' => 255], + [['email'], 'email'], + [['username', 'email'], 'unique'], + ]; } /** * {@inheritdoc} */ - public function getAuthKey() + public function getId(): int|string { - return $this->authKey; + return $this->id; } /** * {@inheritdoc} */ - public function validateAuthKey($authKey) + public function getAuthKey(): ?string { - return $this->authKey === $authKey; + return $this->auth_key; } /** - * Validates password - * - * @param string $password password to validate - * @return boolean if password provided is valid for current user + * {@inheritdoc} */ - public function validatePassword($password) + public function validateAuthKey($authKey): ?bool { - return $this->password === $password; + return $this->auth_key === $authKey; } } diff --git a/api/models/forms/LoginForm.php b/api/models/forms/LoginForm.php new file mode 100644 index 0000000..fefbbf9 --- /dev/null +++ b/api/models/forms/LoginForm.php @@ -0,0 +1,94 @@ +hasErrors()) { + return; + } + + $isExists = User::find() + ->where([ + 'username' => $this->$attribute + ]) + ->exists(); + + if (!$isExists) { + $this->addError( + $attribute, + YII_DEBUG ? 'Username doesn\'t match a user in the database.' : 'Username and password don\'t match', + ); + } + } + + /** + * Validate that the password (once hashed) matches the user record in the + * database, identified by the username. + * + * @param string $attribute Name of the attribute being validated. + * @return void + */ + public function validatePassword(string $attribute): void + { + if ($this->hasErrors()) { + return; + } + + $user = User::find() + ->where([ + 'username' => $this->username, + ]) + ->one(); + + $isPasswordCorrect = Yii::$app->security->validatePassword($this->$attribute, $user->password); + + if (!$isPasswordCorrect) { + $this->addError( + $attribute, + YII_DEBUG ? 'Password doesn\'t match the specified user in the database.' : 'Username and password don\'t match', + ); + } + } +} diff --git a/api/models/forms/SignupForm.php b/api/models/forms/SignupForm.php new file mode 100644 index 0000000..d300ffa --- /dev/null +++ b/api/models/forms/SignupForm.php @@ -0,0 +1,72 @@ +auth_key === null) { + $this->auth_key = Yii::$app->security->generateRandomString(32); + } + + return true; + } + + /** + * {@inheritdoc} + * @throws Exception + */ + public function afterValidate(): void + { + parent::afterValidate(); + + if ($this->password === null && !$this->hasErrors()) { + $this->password = Yii::$app->security->generatePasswordHash($this->new_password); + $this->validate(['password'], false); + } + } + + /** + * todo + */ + +} diff --git a/api/rest/ActiveController.php b/api/rest/ActiveController.php index 9956ec2..0cc10e8 100644 --- a/api/rest/ActiveController.php +++ b/api/rest/ActiveController.php @@ -2,7 +2,15 @@ namespace app\rest; +use app\filters\auth\CookieAuth; +use yii\filters\auth\CompositeAuth; +use yii\filters\auth\QueryParamAuth; +use yii\filters\ContentNegotiator; use yii\filters\Cors; +use yii\filters\VerbFilter; +use yii\helpers\ArrayHelper; +use yii\rest\Serializer; +use yii\web\Response; /** * Class ActiveController @@ -13,11 +21,50 @@ */ class ActiveController extends \yii\rest\ActiveController { + /** + * {@inheritdoc} + */ + public $serializer = [ + 'class' => Serializer::class, + 'collectionEnvelope' => 'items', + ]; + /** * {@inheritdoc} */ public function behaviors(): array { - return ['cors' => ['class' => Cors::class]]; + return [ + 'contentNegotiator' => [ + 'class' => ContentNegotiator::class, + 'formats' => [ + 'application/json' => Response::FORMAT_JSON, + ], + ], + 'cors' => [ + 'class' => Cors::class, + 'cors' => [ + 'Origin' => ['http://localhost:5173'], + 'Access-Control-Request-Method' => [ + 'GET', 'HEAD', 'OPTIONS', + 'DELETE', 'POST', 'PUT' + ], + 'Access-Control-Request-Headers' => ['Content-Type'], + 'Access-Control-Allow-Credentials' => true, + 'Access-Control-Max-Age' => 3600, + ] + ], + 'authenticator' => [ + 'class' => CompositeAuth::class, + 'authMethods' => [ + CookieAuth::class, + QueryParamAuth::class, + ], + ], + 'verbFilter' => [ + 'class' => VerbFilter::class, + 'actions' => $this->verbs(), + ], + ]; } } diff --git a/api/rest/Controller.php b/api/rest/Controller.php index f4f2aaa..bb57d65 100644 --- a/api/rest/Controller.php +++ b/api/rest/Controller.php @@ -2,7 +2,15 @@ namespace app\rest; +use app\filters\auth\CookieAuth; +use yii\filters\auth\CompositeAuth; +use yii\filters\auth\QueryParamAuth; +use yii\filters\ContentNegotiator; use yii\filters\Cors; +use yii\filters\VerbFilter; +use yii\helpers\ArrayHelper; +use yii\rest\Serializer; +use yii\web\Response; /** * Class Controller @@ -13,11 +21,50 @@ */ class Controller extends \yii\rest\Controller { + /** + * @inheritdoc + */ + public $serializer = [ + 'class' => Serializer::class, + 'collectionEnvelope' => 'items', + ]; + /** * {@inheritdoc} */ public function behaviors(): array { - return ['cors' => ['class' => Cors::class]]; + return [ + 'contentNegotiator' => [ + 'class' => ContentNegotiator::class, + 'formats' => [ + 'application/json' => Response::FORMAT_JSON, + ], + ], + 'cors' => [ + 'class' => Cors::class, + 'cors' => [ + 'Origin' => ['http://localhost:5173'], + 'Access-Control-Request-Method' => [ + 'GET', 'HEAD', 'OPTIONS', + 'DELETE', 'POST', 'PUT' + ], + 'Access-Control-Request-Headers' => ['Content-Type'], + 'Access-Control-Allow-Credentials' => true, + 'Access-Control-Max-Age' => 3600, + ] + ], + 'authenticator' => [ + 'class' => CompositeAuth::class, + 'authMethods' => [ + QueryParamAuth::class, + CookieAuth::class + ] + ], + 'verbFilter' => [ + 'class' => VerbFilter::class, + 'actions' => $this->verbs(), + ], + ]; } } diff --git a/api/validators/BlankNewItemValidator.php b/api/validators/BlankNewItemValidator.php new file mode 100644 index 0000000..b19fea1 --- /dev/null +++ b/api/validators/BlankNewItemValidator.php @@ -0,0 +1,37 @@ +$attribute === '') + { + $tableName = $model->tableNameSingular(); + $userId = Yii::$app->user->id; + $count = $model::find()->where(['user_id' => $userId])->count(); + $model->$attribute = "Unnamed $tableName $count"; + } + } +} diff --git a/api/validators/EmptyStringValidator.php b/api/validators/EmptyStringValidator.php new file mode 100644 index 0000000..b951eb1 --- /dev/null +++ b/api/validators/EmptyStringValidator.php @@ -0,0 +1,28 @@ +$attribute === '') + { + $model->$attribute = null; + } + } +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 73a0fc0..b75d833 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,18 +1,18 @@ -import js from "@eslint/js"; -import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import tseslint from "typescript-eslint"; -import { defineConfig, globalIgnores } from "eslint/config"; +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(["dist"]), + globalIgnores(['dist']), { - files: ["**/*.{ts,tsx}"], + files: ['**/*.{ts,tsx}'], extends: [ js.configs.recommended, tseslint.configs.recommended, - reactHooks.configs["recommended-latest"], + reactHooks.configs['recommended-latest'], reactRefresh.configs.vite, ], languageOptions: { @@ -20,8 +20,10 @@ export default defineConfig([ globals: globals.browser, }, rules: { - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": "off", + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-expressions': 'off', }, }, -]); +]) diff --git a/frontend/index.html b/frontend/index.html index 072a57e..bb735bc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@
- +
+ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2ec71a1..7282391 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.0", "dependencies": { "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.5", + "react-select": "^5.10.2" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -27,6 +29,145 @@ "vite": "npm:rolldown-vite@7.1.14" } }, + "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==", + "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/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@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-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-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==", + "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/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.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==", + "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.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", @@ -61,6 +202,120 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -218,6 +473,31 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -270,6 +550,41 @@ "url": "https://github.com/sponsors/nzakas" } }, + "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/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/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": "1.0.7", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", @@ -847,11 +1162,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -867,6 +1187,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", @@ -1215,6 +1544,21 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1250,7 +1594,6 @@ "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" @@ -1300,6 +1643,46 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "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/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1319,14 +1702,12 @@ "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.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1357,11 +1738,29 @@ "node": ">=8" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "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" @@ -1634,6 +2033,12 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1687,6 +2092,15 @@ "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/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1730,6 +2144,27 @@ "node": ">=8" } }, + "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/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/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1744,7 +2179,6 @@ "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", @@ -1767,6 +2201,27 @@ "node": ">=0.8.19" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "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-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1807,6 +2262,12 @@ "dev": true, "license": "ISC" }, + "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.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -1820,6 +2281,18 @@ "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==", + "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", @@ -1827,6 +2300,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "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", @@ -2126,6 +2605,12 @@ "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", @@ -2149,6 +2634,24 @@ "dev": true, "license": "MIT" }, + "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/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/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2190,7 +2693,6 @@ "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/nanoid": { @@ -2219,6 +2721,15 @@ "dev": true, "license": "MIT" }, + "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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2273,7 +2784,6 @@ "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" @@ -2282,6 +2792,24 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2302,11 +2830,25 @@ "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-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "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": { @@ -2377,6 +2919,17 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "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==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2429,11 +2982,111 @@ "react": "^19.2.0" } }, + "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/react-router": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", + "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==", + "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.9.5", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz", + "integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.5" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-select": { + "version": "5.10.2", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", + "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "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": "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" @@ -2534,6 +3187,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2557,6 +3216,15 @@ "node": ">=8" } }, + "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", @@ -2580,6 +3248,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2593,6 +3267,18 @@ "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/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2743,6 +3429,20 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "name": "rolldown-vite", "version": "7.1.14", diff --git a/frontend/package.json b/frontend/package.json index 280f597..aee147b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,9 @@ }, "dependencies": { "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.5", + "react-select": "^5.10.2" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e48e0f..2030491 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,22 +1,38 @@ -import { useState } from "react"; - -export default function BackendTest() { - const [response, setResponse] = useState(""); - - async function testBackend() { - try { - const res = await fetch("http://127.0.0.1:8000/health/check"); - const json = await res.json(); - setResponse(JSON.stringify(json)); - } catch (err) { - console.error(err); - } - } +import { Routes, Route, Link } from 'react-router-dom' +import Home from './pages/Home' +import Test from './pages/Test.tsx' +import Categories from './pages/categories/Categories.tsx' +import Category from './pages/categories/Category.tsx' +import Teams from './pages/teams/Teams.tsx' +import Team from './pages/teams/Team.tsx' +function App() { return (
- -
{response}
+ + + + } /> + } /> + } /> + } /> + } /> + } /> +
- ); + ) } + +export default App +/* todo: + - add caching layer to categories, teams, etc. index calls + - make full login page wireframe + - fetch moves for pokemon on team page + - fix rest of pages to use the new default naming middleware that categories now uses to reduce api calls + - add menu pages for teams, boxes, etc. + - start abstracting out repeated api calls and functions to reduce code duplication + - add a styling palette and start to beautify +*/ diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..ec904f5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,12 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import App from './App.tsx' -createRoot(document.getElementById('root')!).render( - - - , +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + ) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..fb8cf9a --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,22 @@ +export default function Home() { + const BASE_URL = 'http://127.0.0.1:8000' + async function loginDev() { + await fetch(`${BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + username: 'test', + password: 'test', + }), + }) + } + + return ( +
+

Home Page

+ +
+ ) +} diff --git a/frontend/src/pages/Test.tsx b/frontend/src/pages/Test.tsx new file mode 100644 index 0000000..e28fe75 --- /dev/null +++ b/frontend/src/pages/Test.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react' + +const BASE_URL = 'http://127.0.0.1:8000' + +export default function Test() { + const [health, setHealth] = useState('') + + async function healthCheck() { + try { + const json = await ( + await fetch(`${BASE_URL}/health/check`, { credentials: 'include' }) + ).json() + + setHealth(JSON.stringify(json)) + } catch (err) { + console.error(err) + } + } + + return ( +
+ +
{health}
+
+ ) +} diff --git a/frontend/src/pages/categories/Categories.tsx b/frontend/src/pages/categories/Categories.tsx new file mode 100644 index 0000000..bdd02a6 --- /dev/null +++ b/frontend/src/pages/categories/Categories.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import CategoryMenu from './CategoryMenu.tsx' +import { createPortal } from 'react-dom' + +export default function Categories() { + const [categories, setCategories] = useState<{ button: any }[]>([]) + const [isCategoryMenuVisible, setIsCategoryMenuVisible] = useState(false) + const [categoryMenuPosition, setCategoryMenuPosition] = useState<{ x: number; y: number }>({ + x: 0, + y: 0, + }) + const BASE_URL = 'http://127.0.0.1:8000' + const portalRoot = document.getElementById('portal-root') + + async function createNewCategory() { + const userId: number = 1 // (placeholder) todo: get userId of currently logged in user / localstorage + + await fetch(`${BASE_URL}/categories/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user_id: userId, + name: '', // let server allocate a default name + created_at: '2007-09-24 00:00:00', // todo: get current date in this format + }), + }) + .then(res => res.json()) + .then(data => { + console.log(data) + }) + .catch(err => console.log(err)) + + await loadCategories() + } + + async function loadCategories() { + const json = await (await fetch(`${BASE_URL}/categories/index`)).json() + const category = json.items.map((item: any) => { + return { + button: ( + + ), + } + }) + setCategories(category) + } + + const showCategoryMenu = (c: any) => { + c.preventDefault() + setIsCategoryMenuVisible(true) + setCategoryMenuPosition({ x: c.pageX, y: c.pageY }) + } + + const hideCategoryMenu = (c: any) => { + c.preventDefault() + setIsCategoryMenuVisible(false) + } + + useEffect(() => { + loadCategories().then() + }, []) + + return ( +
+ + {isCategoryMenuVisible && + createPortal( + , + portalRoot ?? document.body + )} +
+ {categories.map(category => category.button)} + +
+
+ ) +} diff --git a/frontend/src/pages/categories/Category.tsx b/frontend/src/pages/categories/Category.tsx new file mode 100644 index 0000000..52f8c7e --- /dev/null +++ b/frontend/src/pages/categories/Category.tsx @@ -0,0 +1,15 @@ +import { useParams } from 'react-router-dom' +import Teams from '../teams/Teams.tsx' + +export default function Category() { + const { categoryId } = useParams() + + return ( +
+

category {categoryId}

+
+ {/* just using the test page for now, todo: replace with proper page */} +
+
+ ) +} diff --git a/frontend/src/pages/categories/CategoryMenu.tsx b/frontend/src/pages/categories/CategoryMenu.tsx new file mode 100644 index 0000000..ea3c185 --- /dev/null +++ b/frontend/src/pages/categories/CategoryMenu.tsx @@ -0,0 +1,29 @@ +export default function CategoryMenu({ x, y }: { x: number; y: number }) { + return ( +
+
    +
  • + +
  • +
  • + +
  • +
+
+ ) +} diff --git a/frontend/src/pages/teams/Team.tsx b/frontend/src/pages/teams/Team.tsx new file mode 100644 index 0000000..f835af6 --- /dev/null +++ b/frontend/src/pages/teams/Team.tsx @@ -0,0 +1,135 @@ +import { useParams } from 'react-router-dom' +import { useEffect, useState } from 'react' +import Select from 'react-select' + +const BASE_URL = 'http://127.0.0.1:8000' + +type PokemonInstance = { + id: number + box_id?: number + team_id?: number + pokemon_species_id: number + custom_name: string + format_id?: number +} + +type PokemonSpecies = { + id: number + image: number + name: string + url: string +} +export default function Team() { + const [pokemon, setPokemon] = useState<{ button: any }[]>([]) + const { teamId } = useParams() + + const [dropdownItems, setDropdownItems] = useState<{ value: number | null; label: string }[]>([ + { + value: null, + label: 'Select A Pokemon', + }, + ]) + const [selectedItem, setSelectedItem] = useState<{ value: number | null; label: string }>({ + value: null, + label: 'Select A Pokemon', + }) + + async function loadDropdownItems() { + try { + const json = await (await fetch(`${BASE_URL}/pokemon-species/index/?per-page=1000`)).json() + const items = json.items.map((item: PokemonSpecies) => { + return { + value: item.id, + label: item.name, + } + }) + setDropdownItems(items) + } catch (err) { + console.error(err) + } + } + + async function loadPokemon() { + const json = await (await fetch(`${BASE_URL}/teams/pokemon-index?teamId=${teamId}`)).json() + + const species: { image: string; name: string }[] = json.species.map((item: PokemonSpecies) => { + return { + image: item.image, + name: item.name, + } + }) + + const instance: { id: number }[] = json.instances.map((item: PokemonInstance) => { + return { + id: item.id, + } + }) + + const pokemon = instance.map((item, index) => { + const id = item.id + const imageUrl = species[index].image + const name = species[index].name + + const imageTag = {name} + return { + button: , + } + }) + + setPokemon(pokemon) + } + + async function addPokemon() { + if (!selectedItem.value) return + await fetch(`${BASE_URL}/pokemon-instances/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + // todo: add functionality for all of these columns that are currently using test values: + body: JSON.stringify({ + team_id: teamId, + box_id: null, // todo: check if this is a box or team page + pokemon_species_id: selectedItem.value, + format_id: null, + custom_name: 'unnamed', + // created_at: '2007-09-24 00:00:00', + }), + }) + loadPokemon().then() + } + + useEffect(() => { + loadDropdownItems().then() + }, []) + + useEffect(() => { + loadPokemon().then() + }, []) + + return ( +
+
+