implement part 3
This commit is contained in:
parent
c7707fc94a
commit
f2f7e4e863
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20210901131245 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE SEQUENCE person_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
||||||
|
$this->addSql('CREATE TABLE person (id INT NOT NULL, country_id INT DEFAULT NULL, state_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, surname VARCHAR(255) NOT NULL, city VARCHAR(255) DEFAULT NULL, zip VARCHAR(7) DEFAULT NULL, avatar VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_34DCD176F92F3E70 ON person (country_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_34DCD1765D83CC1 ON person (state_id)');
|
||||||
|
$this->addSql('ALTER TABLE person ADD CONSTRAINT FK_34DCD176F92F3E70 FOREIGN KEY (country_id) REFERENCES country (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE person ADD CONSTRAINT FK_34DCD1765D83CC1 FOREIGN KEY (state_id) REFERENCES country_region (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT fk_8d93d649f92f3e70');
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT fk_8d93d6495d83cc1');
|
||||||
|
$this->addSql('DROP INDEX idx_8d93d649f92f3e70');
|
||||||
|
$this->addSql('DROP INDEX idx_8d93d6495d83cc1');
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD person_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP country_id');
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP state_id');
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP name');
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP surname');
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP avatar');
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D649217BBB47 FOREIGN KEY (person_id) REFERENCES person (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649217BBB47 ON "user" (person_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE SCHEMA public');
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D649217BBB47');
|
||||||
|
$this->addSql('DROP SEQUENCE person_id_seq CASCADE');
|
||||||
|
$this->addSql('DROP TABLE person');
|
||||||
|
$this->addSql('DROP INDEX UNIQ_8D93D649217BBB47');
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD state_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD name VARCHAR(255) NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD surname VARCHAR(255) NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD avatar VARCHAR(255) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE "user" RENAME COLUMN person_id TO country_id');
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD CONSTRAINT fk_8d93d649f92f3e70 FOREIGN KEY (country_id) REFERENCES country (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD CONSTRAINT fk_8d93d6495d83cc1 FOREIGN KEY (state_id) REFERENCES country_region (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('CREATE INDEX idx_8d93d649f92f3e70 ON "user" (country_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_8d93d6495d83cc1 ON "user" (state_id)');
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Entity\Person;
|
||||||
use App\Traits\JsonResponseTrait;
|
use App\Traits\JsonResponseTrait;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
@ -37,13 +38,20 @@ class AuthController extends AbstractController
|
||||||
return $this->notAcceptable("User email exists");
|
return $this->notAcceptable("User email exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$person = new Person();
|
||||||
|
$person->setName($name);
|
||||||
|
$person->setSurname($surname);
|
||||||
|
$person->generateAvatar($email);
|
||||||
|
$em->persist($person);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
$user = new User();
|
$user = new User();
|
||||||
$user->setPassword($encoder->hashPassword($user, $password));
|
$user->setPassword($encoder->hashPassword($user, $password));
|
||||||
$user->setEmail($email);
|
$user->setEmail($email);
|
||||||
$user->setName($name);
|
$user->setPerson($person);
|
||||||
$user->setSurname($surname);
|
|
||||||
$em->persist($user);
|
$em->persist($user);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
return $this->created($user);
|
return $this->created($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +68,7 @@ class AuthController extends AbstractController
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
return $this->unauthorized([]);
|
return $this->unauthorized([]);
|
||||||
}
|
}
|
||||||
$user->generateAvatar();
|
$user->getPerson()->generateAvatar($user->getEmail());
|
||||||
return $this->ok($user);
|
return $this->ok($user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Traits\JsonResponseTrait;
|
||||||
|
use App\Repository\PersonRepository;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
|
||||||
|
class PersonController extends AbstractController
|
||||||
|
{
|
||||||
|
use JsonResponseTrait;
|
||||||
|
|
||||||
|
public function __construct(private PersonRepository $personRepository) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/person/{personId}', methods: ["GET"])]
|
||||||
|
public function show(int $personId)
|
||||||
|
{
|
||||||
|
$person = $this->personRepository->get($personId);
|
||||||
|
return $this->ok($person);
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,7 +15,7 @@ class BaseEntity {
|
||||||
$output = [];
|
$output = [];
|
||||||
$defaultContext = [
|
$defaultContext = [
|
||||||
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => function ($object, $format, $context) {
|
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => function ($object, $format, $context) {
|
||||||
return $object->getName();
|
return '$' . basename(str_replace('\\', '/', $object::class));
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
$normalizers = [new ObjectNormalizer(defaultContext: $defaultContext)];
|
$normalizers = [new ObjectNormalizer(defaultContext: $defaultContext)];
|
||||||
|
|
|
@ -0,0 +1,150 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use App\Repository\PersonRepository;
|
||||||
|
use App\Entity\Abstraction\BaseEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Entity(repositoryClass=PersonRepository::class)
|
||||||
|
*/
|
||||||
|
class Person extends BaseEntity
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @ORM\Id
|
||||||
|
* @ORM\GeneratedValue
|
||||||
|
* @ORM\Column(type="integer")
|
||||||
|
*/
|
||||||
|
private $id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="string", length=255)
|
||||||
|
*/
|
||||||
|
private $name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="string", length=255)
|
||||||
|
*/
|
||||||
|
private $surname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\ManyToOne(targetEntity=Country::class, nullable=true)
|
||||||
|
*/
|
||||||
|
private $country;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\ManyToOne(targetEntity=CountryRegion::class, nullable=true)
|
||||||
|
*/
|
||||||
|
private $state;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="string", length=255, nullable=true)
|
||||||
|
*/
|
||||||
|
private $city;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="string", length=7, nullable=true)
|
||||||
|
*/
|
||||||
|
private $zip;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="string", length=255, nullable=true)
|
||||||
|
*/
|
||||||
|
private $avatar;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): self
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSurname(): ?string
|
||||||
|
{
|
||||||
|
return $this->surname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSurname(string $surname): self
|
||||||
|
{
|
||||||
|
$this->surname = $surname;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCountry(): ?Country
|
||||||
|
{
|
||||||
|
return $this->country;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCountry(?Country $country): self
|
||||||
|
{
|
||||||
|
$this->country = $country;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getState(): ?CountryRegion
|
||||||
|
{
|
||||||
|
return $this->state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setState(?CountryRegion $state): self
|
||||||
|
{
|
||||||
|
$this->state = $state;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCity(): ?string
|
||||||
|
{
|
||||||
|
return $this->city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCity(string $city): self
|
||||||
|
{
|
||||||
|
$this->city = $city;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getZip(): ?string
|
||||||
|
{
|
||||||
|
return $this->zip;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setZip(string $zip): self
|
||||||
|
{
|
||||||
|
$this->zip = $zip;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAvatar(): ?string
|
||||||
|
{
|
||||||
|
return $this->avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAvatar(string $avatar): self
|
||||||
|
{
|
||||||
|
$this->avatar = $avatar;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateAvatar($email) {
|
||||||
|
if (!$this->avatar) {
|
||||||
|
$this->avatar = 'https://www.gravatar.com/avatar/' . md5($email) . '?s=512&d=robohash';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,10 +2,11 @@
|
||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Person;
|
||||||
|
use App\Enums\UserRoleEnum;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use App\Entity\Abstraction\BaseEntity;
|
use App\Entity\Abstraction\BaseEntity;
|
||||||
use App\Enums\UserRoleEnum;
|
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\Security\User\JWTUserInterface;
|
use Lexik\Bundle\JWTAuthenticationBundle\Security\User\JWTUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
|
@ -42,29 +43,9 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse
|
||||||
private $email;
|
private $email;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="string", length=255)
|
* @ORM\OneToOne(targetEntity=Person::class, cascade={"persist", "remove"})
|
||||||
*/
|
*/
|
||||||
private $name;
|
private $person;
|
||||||
|
|
||||||
/**
|
|
||||||
* @ORM\Column(type="string", length=255)
|
|
||||||
*/
|
|
||||||
private $surname;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @ORM\Column(type="string", length=255, nullable=true)
|
|
||||||
*/
|
|
||||||
private $avatar;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @ORM\ManyToOne(targetEntity=Country::class)
|
|
||||||
*/
|
|
||||||
private $country;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @ORM\ManyToOne(targetEntity=CountryRegion::class)
|
|
||||||
*/
|
|
||||||
private $state;
|
|
||||||
|
|
||||||
|
|
||||||
public static function createFromPayload($username, array $payload)
|
public static function createFromPayload($username, array $payload)
|
||||||
|
@ -174,68 +155,14 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getName(): ?string
|
public function getPerson(): ?Person
|
||||||
{
|
{
|
||||||
return $this->name;
|
return $this->person;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setName(string $name): self
|
public function setPerson(?Person $person): self
|
||||||
{
|
{
|
||||||
$this->name = $name;
|
$this->person = $person;
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSurname(): ?string
|
|
||||||
{
|
|
||||||
return $this->surname;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setSurname(string $surname): self
|
|
||||||
{
|
|
||||||
$this->surname = $surname;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAvatar(): ?string
|
|
||||||
{
|
|
||||||
return $this->avatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setAvatar(?string $avatar): self
|
|
||||||
{
|
|
||||||
$this->avatar = $avatar;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generateAvatar() {
|
|
||||||
if (!$this->avatar) {
|
|
||||||
$this->avatar = 'https://www.gravatar.com/avatar/' . md5($this->email) . '?s=514&d=robohash';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCountry(): ?Country
|
|
||||||
{
|
|
||||||
return $this->country;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setCountry(?Country $country): self
|
|
||||||
{
|
|
||||||
$this->country = $country;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getState(): ?CountryRegion
|
|
||||||
{
|
|
||||||
return $this->state;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setState(?CountryRegion $state): self
|
|
||||||
{
|
|
||||||
$this->state = $state;
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Person;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method Person|null find($id, $lockMode = null, $lockVersion = null)
|
||||||
|
* @method Person|null findOneBy(array $criteria, array $orderBy = null)
|
||||||
|
* @method Person[] findAll()
|
||||||
|
* @method Person[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||||
|
*/
|
||||||
|
class PersonRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Person::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @return Person[] Returns an array of Person objects
|
||||||
|
// */
|
||||||
|
/*
|
||||||
|
public function findByExampleField($value)
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.exampleField = :val')
|
||||||
|
->setParameter('val', $value)
|
||||||
|
->orderBy('p.id', 'ASC')
|
||||||
|
->setMaxResults(10)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function get(int $personId): ?Person
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.id = :id')
|
||||||
|
->setParameter('id', $personId)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
|
@ -41,8 +41,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex user-box" *ngIf="user">
|
<div class="d-flex user-box" *ngIf="user">
|
||||||
<div class="avatar" [style.background-image]="url(user.avatar)"></div>
|
<div class="avatar" [style.background-image]="url(user.person.avatar)"></div>
|
||||||
<div class="d-flex align-items-center flex-grow-1 p-2">{{ user.name }} {{ user.surname }}</div>
|
<div class="d-flex align-items-center flex-grow-1 p-2">{{ user.person.name }} {{ user.person.surname }}</div>
|
||||||
<div class="user-menu-container">
|
<div class="user-menu-container">
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button mat-icon-button [matMenuTriggerFor]="usermenu">
|
<button mat-icon-button [matMenuTriggerFor]="usermenu">
|
||||||
|
|
|
@ -91,4 +91,6 @@ $sidebar-width: 250px;
|
||||||
}
|
}
|
||||||
.content{
|
.content{
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { UserModel } from "./user.model";
|
||||||
|
|
||||||
|
export class PersonModel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
avatar: string | null;
|
||||||
|
country: number | null;
|
||||||
|
state: string | null;
|
||||||
|
|
||||||
|
constructor(data: PersonModel) {
|
||||||
|
if (data) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,17 @@
|
||||||
import { UserRoleEnum } from '../enums/user-role.enum';
|
import { UserRoleEnum } from '../enums/user-role.enum';
|
||||||
|
import { PersonModel } from './person.model';
|
||||||
|
|
||||||
export interface UserModel {
|
export class UserModel {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
|
||||||
surname: string;
|
|
||||||
avatar: string | null;
|
|
||||||
country: number | null;
|
|
||||||
state: string | null;
|
|
||||||
roles: UserRoleEnum[];
|
roles: UserRoleEnum[];
|
||||||
|
person: PersonModel;
|
||||||
|
|
||||||
|
constructor(data?: UserModel) {
|
||||||
|
if (data) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
if (data.person && typeof data.person !== 'string') {
|
||||||
|
this.person = new PersonModel(data.person);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
<div class="full-height-form">
|
<div class="full-height-form">
|
||||||
<div class="form-content position-relative">
|
<div class="form-content position-relative">
|
||||||
<div class="scrollable-content" (scroll)="scroll($event)">
|
<div>
|
||||||
<div [style.height.px]="placeholderBoxHeight"></div>
|
|
||||||
<div class="container" [formGroup]="form">
|
<div class="container" [formGroup]="form">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
@ -103,7 +102,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-footer">
|
|
||||||
<button mat-flat-button color="primary">zapisz</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ng-template #footer>
|
||||||
|
<div class="form-footer">
|
||||||
|
<button mat-flat-button color="primary">{{ 'PROFILE.SAVE' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
.container{
|
.container{
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
.form-footer{
|
||||||
|
width: 100%;
|
||||||
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
||||||
import { FormBuilder, FormGroup } from '@ng-stack/forms';
|
import { FormBuilder, FormGroup } from '@ng-stack/forms';
|
||||||
import { Observable, Subject } from 'rxjs';
|
import { Observable, Subject } from 'rxjs';
|
||||||
import { CountryModel } from 'src/app/modules/shared/models/country.model';
|
import { CountryModel } from 'src/app/modules/shared/models/country.model';
|
||||||
import { CountryService } from 'src/app/modules/shared/services/country/country.service';
|
import { CountryService } from 'src/app/modules/shared/services/country/country.service';
|
||||||
import { map, startWith } from 'rxjs/operators';
|
import { map, startWith } from 'rxjs/operators';
|
||||||
import { AuthService } from 'src/app/modules/auth/services/auth/auth.service';
|
import { AuthService } from 'src/app/modules/auth/services/auth/auth.service';
|
||||||
|
import { ProfileEditComponent } from '../profile-edit/profile-edit.component';
|
||||||
|
|
||||||
interface PhoneEntry{
|
interface PhoneEntry{
|
||||||
number: string;
|
number: string;
|
||||||
|
@ -39,28 +40,27 @@ class ProfileInformationBasicModel {
|
||||||
export class ProfileEditBasicsComponent implements OnInit {
|
export class ProfileEditBasicsComponent implements OnInit {
|
||||||
|
|
||||||
form: FormGroup<ProfileInformationBasicModel>;
|
form: FormGroup<ProfileInformationBasicModel>;
|
||||||
onContentScroll = new Subject<number>();
|
|
||||||
headerHeight = 200;
|
|
||||||
placeholderBoxHeight = 0;
|
|
||||||
filteredCountries: Observable<CountryModel[]>;
|
filteredCountries: Observable<CountryModel[]>;
|
||||||
countryInputChnage = new Subject<string>();
|
countryInputChnage = new Subject<string>();
|
||||||
countries: CountryModel[] = [];
|
countries: CountryModel[] = [];
|
||||||
countryInput = '';
|
countryInput = '';
|
||||||
|
@ViewChild('footer') footerElement: ElementRef;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private countryService: CountryService,
|
private countryService: CountryService,
|
||||||
authService: AuthService,
|
authService: AuthService,
|
||||||
formBuilder: FormBuilder,
|
formBuilder: FormBuilder,
|
||||||
|
parent: ProfileEditComponent,
|
||||||
) {
|
) {
|
||||||
const phones = formBuilder.array<PhoneEntry>([]);
|
const phones = formBuilder.array<PhoneEntry>([]);
|
||||||
const emails = formBuilder.array<EmailEntry>([]);
|
const emails = formBuilder.array<EmailEntry>([]);
|
||||||
const user = authService.getUser();
|
const user = authService.getUser();
|
||||||
|
|
||||||
this.form = formBuilder.group<ProfileInformationBasicModel>({
|
this.form = formBuilder.group<ProfileInformationBasicModel>({
|
||||||
name: user.name,
|
name: parent.person.name,
|
||||||
surname: user.surname,
|
surname: parent.person.surname,
|
||||||
|
|
||||||
country: user.country,
|
country: parent.person.country,
|
||||||
state: 'state',
|
state: 'state',
|
||||||
city: 'city',
|
city: 'city',
|
||||||
zip: 'zip',
|
zip: 'zip',
|
||||||
|
@ -89,14 +89,6 @@ export class ProfileEditBasicsComponent implements OnInit {
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
scroll(e): void {
|
|
||||||
const scrollTop = e.target?.scrollTop;
|
|
||||||
if (Number(scrollTop) === scrollTop) {
|
|
||||||
this.placeholderBoxHeight = Math.min(scrollTop, this.headerHeight);
|
|
||||||
this.onContentScroll.next(scrollTop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filterCountries(name: string): CountryModel[] {
|
filterCountries(name: string): CountryModel[] {
|
||||||
return this.countries.filter(i => i.name.toLowerCase().includes(name));
|
return this.countries.filter(i => i.name.toLowerCase().includes(name));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,46 +1,49 @@
|
||||||
<div>
|
<div class="scroller" [style.--scrollbar-width]="scrollWidth+'px'">
|
||||||
<div class="toolbar-background user-cover"></div>
|
<div class="user-cover">
|
||||||
|
<div class="toolbar-background"></div>
|
||||||
|
</div>
|
||||||
<div class="user-header-container">
|
<div class="user-header-container">
|
||||||
<div class="scrollable-header" [style.--scrolled]="scrolled">
|
<div class="header" #scrollHeader [style.margin-top.px]="-headerMove">
|
||||||
<div #headerContent>
|
<div class="p-4">
|
||||||
<div class="p-4" >
|
<mat-card *ngIf="person === undefined" class="loading-card">
|
||||||
<mat-card *ngIf="user === null" class="loading-card">
|
loading
|
||||||
loading
|
</mat-card>
|
||||||
</mat-card>
|
<mat-card *ngIf="person !== undefined">
|
||||||
<mat-card *ngIf="user !== null">
|
<div class="user-header">
|
||||||
<div class="user-header">
|
<div>
|
||||||
<div>
|
<div class="user-avatar" [style.background-image]="url(person.avatar)"></div>
|
||||||
<div class="user-avatar" [style.background-image]="url(user.avatar)"></div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-end flex-grow-1">
|
|
||||||
<h3>{{ user.name }} {{ user.surname }}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</mat-card>
|
<div class="d-flex align-items-end flex-grow-1">
|
||||||
|
<h3>{{ person.name }} {{ person.surname }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="person !== null" class="main-background">
|
||||||
|
<div class="p-3 d-block d-md-none">
|
||||||
|
<h6>{{ currentTabName | translate }}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="tab-container">
|
||||||
|
<mat-tab-group [selectedIndex]="selectedIndex" (selectedIndexChange)="selectedIndexChange($event)">
|
||||||
|
<ng-container *ngFor="let tab of tabs">
|
||||||
|
<mat-tab>
|
||||||
|
<ng-template mat-tab-label>
|
||||||
|
<i *ngIf="tab.icon" class="me-0 me-md-3" [ngClass]="tab.icon"></i>
|
||||||
|
<span class="d-none d-md-inline-block">{{ tab.label | translate }}</span>
|
||||||
|
</ng-template>
|
||||||
|
</mat-tab>
|
||||||
|
</ng-container>
|
||||||
|
</mat-tab-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="user !== null" class="main-background">
|
<div class="content" #scrollContent [style.padding-top.px]="headerHeight" (scroll)="onscrol($event)">
|
||||||
<div class="p-3 d-block d-md-none">
|
<div class="h-100 main-background" *ngIf="person !== undefined && loaded === true">
|
||||||
<h6>{{ currentTabName | translate }}</h6>
|
<router-outlet (activate)="activated($event)" (deactivate)="deactivated($event)"></router-outlet>
|
||||||
</div>
|
|
||||||
<div class="tab-container">
|
|
||||||
<mat-tab-group [selectedIndex]="selectedIndex" (selectedIndexChange)="selectedIndexChange($event)">
|
|
||||||
<ng-container *ngFor="let tab of tabs">
|
|
||||||
<mat-tab>
|
|
||||||
<ng-template mat-tab-label>
|
|
||||||
<i *ngIf="tab.icon" class="me-0 me-md-3" [ngClass]="tab.icon"></i>
|
|
||||||
<span class="d-none d-md-inline-block">{{ tab.label | translate }}</span>
|
|
||||||
</ng-template>
|
|
||||||
</mat-tab>
|
|
||||||
</ng-container>
|
|
||||||
</mat-tab-group>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="footer" *ngIf="footerElement !== undefined">
|
||||||
<div class="flex-grow-1 main-background" *ngIf="user !== null && loaded === true">
|
<ng-container *ngTemplateOutlet="footerElement"></ng-container>
|
||||||
<div class="h-100">
|
|
||||||
<router-outlet (activate)="activated($event)" (deactivate)="deactivated($event)"></router-outlet>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,21 +3,19 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.scrollable-header{
|
|
||||||
overflow: hidden;
|
|
||||||
> * {
|
|
||||||
margin-top: calc(var(--scrolled) * -1px);
|
|
||||||
margin-bottom: calc(var(--scrolled) * -1px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.user-cover{
|
.user-cover{
|
||||||
height: 100px;
|
|
||||||
background-color: var(--toolbar-background);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
|
height: 0px;
|
||||||
|
width: calc(100% - var(--scrollbar-width));
|
||||||
|
div{
|
||||||
|
display: block;
|
||||||
|
content: "";
|
||||||
|
height: 100px;
|
||||||
|
background-color: var(--toolbar-background);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.user-header-container{
|
.user-header-container{
|
||||||
margin-top: -100px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
@ -43,3 +41,31 @@
|
||||||
.main-background{
|
.main-background{
|
||||||
background: var(--main-background);
|
background: var(--main-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.scroller{
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
--scrollbar-width: 0px;
|
||||||
|
.header{
|
||||||
|
position: relative;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
width: calc(100% - var(--scrollbar-width));
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.content{
|
||||||
|
position: relative;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,11 @@
|
||||||
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
||||||
import { DomSanitizer, SafeStyle } from '@angular/platform-browser';
|
import { DomSanitizer, SafeStyle } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, ActivatedRouteSnapshot, ActivationStart, Data, Router, RoutesRecognized } from '@angular/router';
|
import { ActivatedRoute, ActivatedRouteSnapshot, ActivationStart, Data, Router, RoutesRecognized } from '@angular/router';
|
||||||
import { Subscription } from 'rxjs';
|
import { PersonModel } from 'src/app/modules/auth/models/person.model';
|
||||||
import { UserModel } from 'src/app/modules/auth/models/user.model';
|
|
||||||
import { AuthService } from 'src/app/modules/auth/services/auth/auth.service';
|
import { AuthService } from 'src/app/modules/auth/services/auth/auth.service';
|
||||||
import { ProfileTabEnum } from '../../enums/profile-tab.enum';
|
import { ProfileTabEnum } from '../../enums/profile-tab.enum';
|
||||||
import { profileTabRoutes } from '../../profile-routing';
|
import { profileTabRoutes } from '../../profile-routing';
|
||||||
|
import { PersonService } from '../../service/person/person.service';
|
||||||
|
|
||||||
interface ProfileTab {
|
interface ProfileTab {
|
||||||
tab: ProfileTabEnum;
|
tab: ProfileTabEnum;
|
||||||
|
@ -21,15 +20,19 @@ interface ProfileTab {
|
||||||
})
|
})
|
||||||
export class ProfileEditComponent implements AfterViewInit {
|
export class ProfileEditComponent implements AfterViewInit {
|
||||||
|
|
||||||
scrollSubscriptions: Subscription[] = [];
|
|
||||||
scrolled = 0;
|
|
||||||
profileTabRoutes = profileTabRoutes;
|
profileTabRoutes = profileTabRoutes;
|
||||||
currentTabName: string;
|
currentTabName: string;
|
||||||
user: UserModel | null = null;
|
person: PersonModel | null = null;
|
||||||
selectedIndex = 0;
|
selectedIndex = 0;
|
||||||
defaultProfileTab = ProfileTabEnum.Basics;
|
defaultProfileTab = ProfileTabEnum.Basics;
|
||||||
|
footerElement: ElementRef;
|
||||||
loaded = false;
|
loaded = false;
|
||||||
@ViewChild('headerContent', {static: false}) headerContent: ElementRef;
|
@ViewChild('scrollHeader', {static: false}) scrollHeader: ElementRef;
|
||||||
|
@ViewChild('scrollContent', {static: false}) scrollContent: ElementRef;
|
||||||
|
headerHeight = 200;
|
||||||
|
headerMove = 0;
|
||||||
|
maxHeaderMove = 0;
|
||||||
|
scrollWidth = 30;
|
||||||
tabs: ProfileTab[] = [
|
tabs: ProfileTab[] = [
|
||||||
{
|
{
|
||||||
tab: ProfileTabEnum.Basics,
|
tab: ProfileTabEnum.Basics,
|
||||||
|
@ -63,11 +66,15 @@ export class ProfileEditComponent implements AfterViewInit {
|
||||||
authService: AuthService,
|
authService: AuthService,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
private personService: PersonService,
|
||||||
) {
|
) {
|
||||||
this.user = authService.getUser();
|
const personId = parseInt(activatedRoute.snapshot.params.personId, 10);
|
||||||
authService.userChange.subscribe(user => {
|
if (personId) {
|
||||||
this.user = user;
|
this.loadPerson(personId);
|
||||||
});
|
} else {
|
||||||
|
this.loadPerson(authService.getUser().person.id);
|
||||||
|
}
|
||||||
|
|
||||||
this.onRouteChange(activatedRoute.snapshot.firstChild?.data);
|
this.onRouteChange(activatedRoute.snapshot.firstChild?.data);
|
||||||
this.router.events.subscribe(event => {
|
this.router.events.subscribe(event => {
|
||||||
if (event instanceof RoutesRecognized) {
|
if (event instanceof RoutesRecognized) {
|
||||||
|
@ -76,28 +83,50 @@ export class ProfileEditComponent implements AfterViewInit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadPerson(personId: number) {
|
||||||
|
this.person = undefined;
|
||||||
|
this.loaded = false;
|
||||||
|
this.personService.get(personId).subscribe(person => {
|
||||||
|
this.person = person;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScroller() {
|
||||||
|
this.scrollHeader.nativeElement.style.height = '0px';
|
||||||
|
this.headerHeight = this.scrollHeader.nativeElement.scrollHeight;
|
||||||
|
this.maxHeaderMove = this.scrollHeader.nativeElement.firstChild.scrollHeight;
|
||||||
|
this.scrollWidth = this.scrollContent.nativeElement.parentElement.scrollWidth - this.scrollContent.nativeElement.scrollWidth;
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateScroller();
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
activated(component): void {
|
activated(component) {
|
||||||
if (component.onContentScroll) {
|
if (component.footerElement) {
|
||||||
component.headerHeight = this.headerContent.nativeElement.scrollHeight;
|
this.footerElement = component.footerElement;
|
||||||
setTimeout(() => {
|
|
||||||
component.headerHeight = this.headerContent.nativeElement.scrollHeight;
|
|
||||||
}, 500);
|
|
||||||
this.scrollSubscriptions.push(
|
|
||||||
component.onContentScroll.subscribe(position => {
|
|
||||||
this.scrolled = position / 2;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (component.footerElement) {
|
||||||
|
this.footerElement = component.footerElement;
|
||||||
|
}
|
||||||
|
this.updateScroller();
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivated(component): void {
|
deactivated(component) {
|
||||||
this.scrolled = 0;
|
this.footerElement = undefined;
|
||||||
this.scrollSubscriptions.forEach(i => i.unsubscribe());
|
setTimeout(() => {
|
||||||
this.scrollSubscriptions = [];
|
this.updateScroller();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
onscrol(e) {
|
||||||
|
this.headerMove = Math.min(e.target.scrollTop, this.maxHeaderMove);
|
||||||
}
|
}
|
||||||
|
|
||||||
onRouteChange(data: Data): void {
|
onRouteChange(data: Data): void {
|
||||||
|
|
|
@ -51,4 +51,9 @@ export const profileRoutes: Route[] = [
|
||||||
component: ProfileEditComponent,
|
component: ProfileEditComponent,
|
||||||
children: profileTabRoutes,
|
children: profileTabRoutes,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ':personId',
|
||||||
|
component: ProfileEditComponent,
|
||||||
|
children: profileTabRoutes,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { PersonModel } from 'src/app/modules/auth/models/person.model';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class PersonService {
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {
|
||||||
|
}
|
||||||
|
|
||||||
|
get(personId: number): Observable<PersonModel> {
|
||||||
|
return this.http.get<PersonModel>(`/api/person/${personId}`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
.form-group{
|
.form-group{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin: 1rem;
|
padding: 1rem;
|
||||||
&:not(:last-child){
|
&:not(:last-child){
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
@ -37,17 +37,17 @@
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
.form-footer{
|
}
|
||||||
background: var(--dialog-background);
|
.form-footer{
|
||||||
border-top: 1px solid var(--divider-color);
|
background: var(--dialog-background);
|
||||||
padding: 0.5rem;
|
border-top: 1px solid var(--divider-color);
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
> div{
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
&:first-child{
|
||||||
> div{
|
flex-grow: 1;
|
||||||
display: flex;
|
|
||||||
&:first-child{
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,6 +50,7 @@
|
||||||
"AVATAR": "Avatar",
|
"AVATAR": "Avatar",
|
||||||
"PASSWORD": "Password",
|
"PASSWORD": "Password",
|
||||||
"SITE_SETTINGS": "Site settings",
|
"SITE_SETTINGS": "Site settings",
|
||||||
|
"SAVE": "Save",
|
||||||
"CONTACTS": {
|
"CONTACTS": {
|
||||||
"COUNTRY": "Country",
|
"COUNTRY": "Country",
|
||||||
"STATE": "State",
|
"STATE": "State",
|
||||||
|
|
|
@ -50,6 +50,7 @@
|
||||||
"AVATAR": "Zdjęcie profilowe",
|
"AVATAR": "Zdjęcie profilowe",
|
||||||
"PASSWORD": "Hasło",
|
"PASSWORD": "Hasło",
|
||||||
"SITE_SETTINGS": "Ustawienia strony",
|
"SITE_SETTINGS": "Ustawienia strony",
|
||||||
|
"SAVE": "Zapisz",
|
||||||
"CONTACTS": {
|
"CONTACTS": {
|
||||||
"COUNTRY": "Kraj",
|
"COUNTRY": "Kraj",
|
||||||
"STATE": "Województwo",
|
"STATE": "Województwo",
|
||||||
|
|
Loading…
Reference in New Issue