Compare commits
5 Commits
main
...
5ef4d0f2e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ef4d0f2e0 | ||
|
|
dcc1d55db6 | ||
|
|
95dd13595e | ||
|
|
aec029b670 | ||
|
|
859d5b88bc |
19
.gitignore
vendored
@@ -1,4 +1,15 @@
|
|||||||
.env
|
frontend/.env
|
||||||
node_modules/
|
frontend/node_modules/
|
||||||
dist/
|
frontend/dist/
|
||||||
package-lock.json
|
frontend/package-lock.json
|
||||||
|
|
||||||
|
backend/target/
|
||||||
|
backend/.mvn/wrapper/maven-wrapper.jar
|
||||||
|
backend/!**/src/main/**/target/
|
||||||
|
backend/!**/src/test/**/target/
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
backend/.idea
|
||||||
|
backend/*.iws
|
||||||
|
backend/*.iml
|
||||||
|
backend/*.ipr
|
||||||
|
|||||||
3
backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
wrapperVersion=3.3.4
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
|
||||||
95
backend/pom.xml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.0.2</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>es.adeptusminiaturium</groupId>
|
||||||
|
<artifactId>backend</artifactId>
|
||||||
|
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>backend</name>
|
||||||
|
<description>Adeptus Miniaturium's online site</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.source>25</maven.compiler.source>
|
||||||
|
<maven.compiler.target>25</maven.compiler.target>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>gitea</id>
|
||||||
|
<url>https://git.miarma.net/api/packages/Gallardo7761/maven</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mariadb.jdbc</groupId>
|
||||||
|
<artifactId>mariadb-java-client</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.miarma</groupId>
|
||||||
|
<artifactId>backlib</artifactId>
|
||||||
|
<version>1.1.0</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package es.adeptusminiaturium.backend;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class BackendApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(BackendApplication.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package es.adeptusminiaturium.backend.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
import es.adeptusminiaturium.backend.enums.MediaType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "media")
|
||||||
|
public class Media {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "media_id")
|
||||||
|
private Long mediaId;
|
||||||
|
|
||||||
|
@JsonManagedReference
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "post_id", nullable = false)
|
||||||
|
private Post post;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.ORDINAL)
|
||||||
|
@Column(name = "media_type", nullable = false)
|
||||||
|
private MediaType mediaType = MediaType.IMAGE;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 512)
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer position = 0;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", insertable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public Long getMediaId() {
|
||||||
|
return mediaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMediaId(Long mediaId) {
|
||||||
|
this.mediaId = mediaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Post getPost() {
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPost(Post post) {
|
||||||
|
this.post = post;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MediaType getMediaType() {
|
||||||
|
return mediaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMediaType(MediaType mediaType) {
|
||||||
|
this.mediaType = mediaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUrl(String url) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPosition(Integer position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package es.adeptusminiaturium.backend.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
import es.adeptusminiaturium.backend.enums.PostStatus;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "posts")
|
||||||
|
public class Post {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "post_id")
|
||||||
|
private Long postId;
|
||||||
|
|
||||||
|
@JsonManagedReference
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "author_id", nullable = false)
|
||||||
|
private User author;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String body;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String hashtags;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.ORDINAL)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private PostStatus status = PostStatus.DRAFT;
|
||||||
|
|
||||||
|
@Column(name = "published_at")
|
||||||
|
private LocalDateTime publishedAt;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", insertable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", insertable = false, updatable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private List<Media> media;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private List<Publication> publications;
|
||||||
|
|
||||||
|
public Long getPostId() {
|
||||||
|
return postId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPostId(Long postId) {
|
||||||
|
this.postId = postId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getAuthor() {
|
||||||
|
return author;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuthor(User author) {
|
||||||
|
this.author = author;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBody() {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBody(String body) {
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHashtags() {
|
||||||
|
return hashtags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHashtags(String hashtags) {
|
||||||
|
this.hashtags = hashtags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PostStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(PostStatus status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getPublishedAt() {
|
||||||
|
return publishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublishedAt(LocalDateTime publishedAt) {
|
||||||
|
this.publishedAt = publishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Media> getMedia() {
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMedia(List<Media> media) {
|
||||||
|
this.media = media;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Publication> getPublications() {
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublications(List<Publication> publications) {
|
||||||
|
this.publications = publications;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package es.adeptusminiaturium.backend.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
import es.adeptusminiaturium.backend.enums.Platform;
|
||||||
|
import es.adeptusminiaturium.backend.enums.PublicationStatus;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "publications",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "platform"})
|
||||||
|
)
|
||||||
|
public class Publication {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "publication_id")
|
||||||
|
private Long publicationId;
|
||||||
|
|
||||||
|
@JsonManagedReference
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "post_id", nullable = false)
|
||||||
|
private Post post;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.ORDINAL)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Platform platform;
|
||||||
|
|
||||||
|
private String externalId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.ORDINAL)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private PublicationStatus status = PublicationStatus.PENDING;
|
||||||
|
|
||||||
|
@Column(name = "published_at")
|
||||||
|
private LocalDateTime publishedAt;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", insertable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public Long getPublicationId() {
|
||||||
|
return publicationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublicationId(Long publicationId) {
|
||||||
|
this.publicationId = publicationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Post getPost() {
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPost(Post post) {
|
||||||
|
this.post = post;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Platform getPlatform() {
|
||||||
|
return platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPlatform(Platform platform) {
|
||||||
|
this.platform = platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getExternalId() {
|
||||||
|
return externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExternalId(String externalId) {
|
||||||
|
this.externalId = externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicationStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(PublicationStatus status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getPublishedAt() {
|
||||||
|
return publishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublishedAt(LocalDateTime publishedAt) {
|
||||||
|
this.publishedAt = publishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorMessage(String errorMessage) {
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package es.adeptusminiaturium.backend.model;
|
||||||
|
|
||||||
|
import es.adeptusminiaturium.backend.enums.UserRole;
|
||||||
|
import es.adeptusminiaturium.backend.enums.UserStatus;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
public class User {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "user_id", nullable = false, updatable = false)
|
||||||
|
private UUID userId;
|
||||||
|
|
||||||
|
@Column(name = "display_name")
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
@Column(name = "user_name", nullable = false, unique = true)
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.ORDINAL)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UserRole role = UserRole.USER;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.ORDINAL)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UserStatus status = UserStatus.ACTIVE;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", insertable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", insertable = false, updatable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public UUID getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(UUID userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDisplayName(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserName() {
|
||||||
|
return userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserName(String userName) {
|
||||||
|
this.userName = userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassword(String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatar() {
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatar(String avatar) {
|
||||||
|
this.avatar = avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserRole getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(UserRole role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(UserStatus status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/main/resources/application-dev.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
spring:
|
||||||
|
jpa:
|
||||||
|
show-sql: true
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mariadb://localhost:3306/miniaturium
|
||||||
|
username: admin
|
||||||
|
password: ${DB_PASS}
|
||||||
|
driver-class-name: org.mariadb.jdbc.Driver
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
org.hibernate.SQL: DEBUG
|
||||||
|
org.hibernate.orm.jdbc.bind: TRACE
|
||||||
13
backend/src/main/resources/application-prod.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mariadb://mariadb:3306/miniaturium
|
||||||
|
username: ${DB_USER}
|
||||||
|
password: ${DB_PASS}
|
||||||
|
driver-class-name: org.mariadb.jdbc.Driver
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
org.hibernate.SQL: WARN
|
||||||
25
backend/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: backend
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
open-in-view: false
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
jdbc:
|
||||||
|
time_zone: UTC
|
||||||
|
|
||||||
|
jackson:
|
||||||
|
default-property-inclusion: non_null
|
||||||
|
time-zone: Europe/Madrid
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
expiration-ms: 3600000
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title></title>
|
<title>Adeptus Miniaturium</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root" class="p-0 m-0"></div>
|
<div id="root" class="p-0 m-0"></div>
|
||||||
@@ -19,10 +19,13 @@
|
|||||||
"bootstrap": "^5.3.7",
|
"bootstrap": "^5.3.7",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
|
"i18next": "^25.8.10",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-i18next": "^16.5.4",
|
||||||
"react-router-dom": "^7.7.1",
|
"react-router-dom": "^7.7.1",
|
||||||
|
"react-router-hash-link": "^2.4.3",
|
||||||
"react-slick": "^0.30.3",
|
"react-slick": "^0.30.3",
|
||||||
"slick-carousel": "^1.8.1"
|
"slick-carousel": "^1.8.1"
|
||||||
},
|
},
|
||||||
BIN
frontend/public/images/mini_1.jpeg
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
frontend/public/images/mini_2.jpeg
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
frontend/public/images/mini_3.jpeg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/public/images/mini_4.jpeg
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
frontend/public/images/mini_5.jpeg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
frontend/public/images/pfp.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
26
frontend/src/App.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import Header from '@/components/Header'
|
||||||
|
import NavBar from '@/components/NavBar/NavBar'
|
||||||
|
import Home from '@/pages/Home'
|
||||||
|
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import "./i18n";
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const withoutFooter = ["/login"]
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header profilePic={"/images/pfp.jpg"}/>
|
||||||
|
<NavBar />
|
||||||
|
<Routes>
|
||||||
|
<Route path='/' element={<Home />} />
|
||||||
|
<Route path='/login' element={<Login />} />
|
||||||
|
</Routes>
|
||||||
|
{!withoutFooter.includes(location.pathname) && <Footer />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -11,10 +11,12 @@ import CustomContainer from '@/components/CustomContainer.jsx';
|
|||||||
import ContentWrapper from '@/components/ContentWrapper.jsx';
|
import ContentWrapper from '@/components/ContentWrapper.jsx';
|
||||||
|
|
||||||
import '@/css/LoginForm.css';
|
import '@/css/LoginForm.css';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const LoginForm = () => {
|
const LoginForm = () => {
|
||||||
const { login, error } = useContext(AuthContext);
|
const { login, error } = useContext(AuthContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [formState, setFormState] = useState({
|
const [formState, setFormState] = useState({
|
||||||
emailOrUserName: "",
|
emailOrUserName: "",
|
||||||
@@ -47,24 +49,21 @@ const LoginForm = () => {
|
|||||||
await login(loginBody);
|
await login(loginBody);
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error de login:", err.message);
|
console.error("Error:", err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomContainer>
|
<CustomContainer>
|
||||||
<ContentWrapper>
|
<ContentWrapper>
|
||||||
<div className="login-card card shadow p-5 rounded-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
|
<div className="login-card card shadow rounded-0 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
|
||||||
<h1 className="text-center">Inicio de sesión</h1>
|
<h1 className="text-center">{t("login.title")}</h1>
|
||||||
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
|
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
|
||||||
<div className="d-flex flex-column gap-3">
|
<div className="d-flex flex-column gap-3">
|
||||||
<FloatingLabel
|
<FloatingLabel
|
||||||
controlId="floatingUsuario"
|
controlId="floatingUsuario"
|
||||||
label={
|
label={
|
||||||
<>
|
t("login.user_label")
|
||||||
<FontAwesomeIcon icon={faUser} className="me-2" />
|
|
||||||
Usuario o Email
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -73,7 +72,7 @@ const LoginForm = () => {
|
|||||||
name="emailOrUserName"
|
name="emailOrUserName"
|
||||||
value={formState.emailOrUserName}
|
value={formState.emailOrUserName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="rounded-4"
|
className="rounded-0"
|
||||||
/>
|
/>
|
||||||
</FloatingLabel>
|
</FloatingLabel>
|
||||||
|
|
||||||
@@ -82,20 +81,6 @@ const LoginForm = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
name="password"
|
name="password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
|
|
||||||
<Form.Check
|
|
||||||
type="checkbox"
|
|
||||||
name="keepLoggedIn"
|
|
||||||
label="Mantener sesión iniciada"
|
|
||||||
className="text-secondary"
|
|
||||||
value={formState.keepLoggedIn}
|
|
||||||
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
|
|
||||||
/>
|
|
||||||
{/*<Link disabled to="#" className="muted">
|
|
||||||
Olvidé mi contraseña
|
|
||||||
</Link>*/}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -105,8 +90,8 @@ const LoginForm = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Button type="submit" className="w-75 padding-4 rounded-4 border-0 shadow-sm login-button">
|
<Button type="submit" className="border-0 w-75 padding-4 rounded-0 shadow-sm login-button">
|
||||||
Iniciar sesión
|
{t("login.button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -4,9 +4,11 @@ import '@/css/PasswordInput.css';
|
|||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
|
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const PasswordInput = ({ value, onChange, name = "password" }) => {
|
const PasswordInput = ({ value, onChange, name = "password" }) => {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const toggleShow = () => setShow(prev => !prev);
|
const toggleShow = () => setShow(prev => !prev);
|
||||||
|
|
||||||
@@ -15,10 +17,7 @@ const PasswordInput = ({ value, onChange, name = "password" }) => {
|
|||||||
<FloatingLabel
|
<FloatingLabel
|
||||||
controlId="passwordInput"
|
controlId="passwordInput"
|
||||||
label={
|
label={
|
||||||
<>
|
t("login.password_label")
|
||||||
<FontAwesomeIcon icon={faKey} className="me-2" />
|
|
||||||
Contraseña
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -27,7 +26,7 @@ const PasswordInput = ({ value, onChange, name = "password" }) => {
|
|||||||
value={value}
|
value={value}
|
||||||
placeholder=""
|
placeholder=""
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className="rounded-4 pe-5"
|
className="rounded-0 pe-5"
|
||||||
/>
|
/>
|
||||||
</FloatingLabel>
|
</FloatingLabel>
|
||||||
|
|
||||||
@@ -2,8 +2,10 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
const CustomContainer = ({ children }) => {
|
const CustomContainer = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<main className="px-4 py-5">
|
<main className="container my-5">
|
||||||
|
<div className="d-flex flex-column gap-5">
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
10
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import '@/css/Footer.css'
|
||||||
|
|
||||||
|
const Footer = () => (
|
||||||
|
<footer className="text-center py-5">
|
||||||
|
<span className="skull-icon mb-2">☠</span>
|
||||||
|
<p className="m-0">Adeptus Miniaturium © 41st Millennium</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
21
frontend/src/components/Header.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import '@/css/Header.css';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="imperial-header py-5 text-center position-relative">
|
||||||
|
<img
|
||||||
|
src="/images/purity.png"
|
||||||
|
alt="Purity Seal"
|
||||||
|
className="purity-seal left"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="mb-2">Adeptus Miniaturium</h1>
|
||||||
|
<p className="m-0">{t("header.subtitle")}</p>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
37
frontend/src/components/Home/ScrollBackgroundSpin.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
"/images/mini_1.jpeg",
|
||||||
|
"/images/mini_2.jpeg",
|
||||||
|
"/images/mini_3.jpeg",
|
||||||
|
"/images/mini_4.jpeg",
|
||||||
|
"/images/mini_5.jpeg",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOTAL_FRAMES = images.length;
|
||||||
|
|
||||||
|
const ScrollBackgroundSpin = () => {
|
||||||
|
const [frame, setFrame] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
const maxScroll = document.body.scrollHeight - window.innerHeight;
|
||||||
|
const progress = scrollTop / maxScroll;
|
||||||
|
|
||||||
|
const currentFrame = Math.floor(progress * TOTAL_FRAMES);
|
||||||
|
setFrame(Math.min(currentFrame, TOTAL_FRAMES - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="spin-background">
|
||||||
|
<img src={images[frame]} alt="Miniatura fondo" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollBackgroundSpin;
|
||||||
33
frontend/src/components/LanguageButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import NavItem from '@/components/NavBar/NavItem';
|
||||||
|
|
||||||
|
const LanguageButton = ({ as = "button", index = null }) => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
const toggleLanguage = () => {
|
||||||
|
console.log(i18n.language);
|
||||||
|
const nextLang = i18n.language === "es" ? "en" : "es";
|
||||||
|
i18n.changeLanguage(nextLang);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (as === "navitem") {
|
||||||
|
return (
|
||||||
|
<NavItem
|
||||||
|
item={{
|
||||||
|
label: i18n.language === "es" ? "🇪🇸" : "🇺🇸",
|
||||||
|
href: "#"
|
||||||
|
}}
|
||||||
|
index={index}
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={toggleLanguage} className="btn-imperial">
|
||||||
|
{i18n.language === "es" ? "🇪🇸" : "🇺🇸"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguageButton;
|
||||||
26
frontend/src/components/NavBar/NavBar.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useWindowWidth } from '@/hooks/useWindowWidth';
|
||||||
|
import NavBarDesktop from '@/components/NavBar/desktop/NavBarDesktop';
|
||||||
|
import NavBarMobile from '@/components/NavBar/mobile/NavBarMobile';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const NavBar = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const width = useWindowWidth();
|
||||||
|
const navItems = [
|
||||||
|
{ label: t("nav.inicio.label"), href: `/#${t("nav.inicio.href")}` },
|
||||||
|
{ label: t("nav.muestras.label"), href: `/#${t("nav.muestras.href")}` },
|
||||||
|
{ label: t("nav.pedidos.label"), href: `/#${t("nav.pedidos.href")}` },
|
||||||
|
{ label: t("nav.sobre.label"), href: `/#${t("nav.sobre.href")}` },
|
||||||
|
{ label: t("nav.login.label"), href: `/${t("nav.login.href")}` }
|
||||||
|
];
|
||||||
|
|
||||||
|
return width > 991.98 ? (
|
||||||
|
<nav className="nav-container p-0">
|
||||||
|
<NavBarDesktop navItems={navItems} />
|
||||||
|
</nav>
|
||||||
|
) : (
|
||||||
|
<NavBarMobile navItems={navItems} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavBar;
|
||||||
38
frontend/src/components/NavBar/NavItem.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { HashLink } from "react-router-hash-link/dist/react-router-hash-link.cjs.production";
|
||||||
|
|
||||||
|
const NavItem = ({ item, index, onClick, onCloseNav }) => {
|
||||||
|
let borderClass = `${index === 0 ? "border-left border-right" : "border-right"}`;
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
if (onClick) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
} else if (item.href === "#") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onCloseNav) {
|
||||||
|
onCloseNav();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={`mx-0 ${borderClass}`}>
|
||||||
|
{onClick || item.href === "#" ? (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="nav-button"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<HashLink smooth to={item.href} className="nav-link-custom" onClick={handleClick}>
|
||||||
|
{item.label}
|
||||||
|
</HashLink>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavItem;
|
||||||
30
frontend/src/components/NavBar/desktop/NavBarDesktop.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import NavItem from '@/components/NavBar/NavItem';
|
||||||
|
import LanguageButton from '@/components/LanguageButton';
|
||||||
|
|
||||||
|
import '@/css/NavBar.css';
|
||||||
|
|
||||||
|
const NavBarDesktop = ({ navItems }) => {
|
||||||
|
const centerItems = navItems.slice(0, navItems.length - 1);
|
||||||
|
const rightItems = navItems.slice(-1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="d-flex align-items-center list-unstyled m-0 w-100 justify-content-center position-relative">
|
||||||
|
<li className="position-absolute start-0 d-flex">
|
||||||
|
<LanguageButton as='navitem' index={0} total={rightItems.length + 1} />
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{centerItems.map((item, index) => (
|
||||||
|
<NavItem key={index} item={item} index={index} total={centerItems.length} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<li className="position-absolute end-0 d-flex">
|
||||||
|
{rightItems.map((item, index) => (
|
||||||
|
<NavItem key={index} item={item} index={index} total={rightItems.length} />
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavBarDesktop;
|
||||||
41
frontend/src/components/NavBar/mobile/NavBarMobile.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import NavItem from '@/components/NavBar/NavItem';
|
||||||
|
import LanguageButton from '@/components/LanguageButton';
|
||||||
|
import '@/css/NavBarMobile.css';
|
||||||
|
|
||||||
|
const NavBarMobile = ({ navItems }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="nav-container d-lg-none">
|
||||||
|
<div className="nav-mobile-header d-flex justify-content-between align-items-center">
|
||||||
|
{/* Por si algun dia se pone
|
||||||
|
<div className="nav-mobile-brand"></div>
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
className={`nav-mobile-toggle ms-2 ${open ? 'open' : ''}`}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<ul className="nav-list-mobile d-flex flex-column align-items-center list-unstyled m-0 w-100">
|
||||||
|
{navItems.map((item, index) => (
|
||||||
|
<NavItem key={index} item={item} onCloseNav={handleClose} />
|
||||||
|
))}
|
||||||
|
<LanguageButton as="navitem" />
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavBarMobile;
|
||||||
9
frontend/src/components/TechCard.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import '@/css/TechCard.css'
|
||||||
|
|
||||||
|
const TechCard = ({ children, className = '' }) => (
|
||||||
|
<div className={`tech-card ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default TechCard;
|
||||||
@@ -8,8 +8,12 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const axios = createAxiosInstance();
|
const axios = createAxiosInstance();
|
||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
|
|
||||||
const [user, setUser] = useState(() => JSON.parse(localStorage.getItem("user")) || null);
|
|
||||||
const [token, setToken] = useState(() => localStorage.getItem("token"));
|
const [token, setToken] = useState(() => localStorage.getItem("token"));
|
||||||
|
const [identity, setIdentity] = useState(() => {
|
||||||
|
const stored = localStorage.getItem("identity");
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
});
|
||||||
|
|
||||||
const [authStatus, setAuthStatus] = useState("checking");
|
const [authStatus, setAuthStatus] = useState("checking");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
@@ -29,6 +33,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const res = await axios.get(VALIDATE_URL, {
|
const res = await axios.get(VALIDATE_URL, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
setAuthStatus("authenticated");
|
setAuthStatus("authenticated");
|
||||||
} else {
|
} else {
|
||||||
@@ -45,19 +50,26 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
const login = async (formData) => {
|
const login = async (formData) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const BASE_URL = config.apiConfig.baseUrl;
|
const BASE_URL = config.apiConfig.baseUrl;
|
||||||
const LOGIN_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.login}`;
|
const LOGIN_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.login}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(LOGIN_URL, formData);
|
const res = await axios.post(LOGIN_URL, formData);
|
||||||
const { token, member, tokenTime } = res.data.data;
|
|
||||||
|
const { token, user, account, metadata } = res.data;
|
||||||
|
|
||||||
|
const identity = {
|
||||||
|
user,
|
||||||
|
account,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
localStorage.setItem("token", token);
|
localStorage.setItem("token", token);
|
||||||
localStorage.setItem("user", JSON.stringify(member));
|
localStorage.setItem("identity", JSON.stringify(identity));
|
||||||
localStorage.setItem("tokenTime", tokenTime);
|
|
||||||
|
|
||||||
setToken(token);
|
setToken(token);
|
||||||
setUser(member);
|
setIdentity(identity);
|
||||||
setAuthStatus("authenticated");
|
setAuthStatus("authenticated");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error al iniciar sesión:", err);
|
console.error("Error al iniciar sesión:", err);
|
||||||
@@ -70,7 +82,54 @@ export const AuthProvider = ({ children }) => {
|
|||||||
if (status === 400) {
|
if (status === 400) {
|
||||||
message = "Usuario o contraseña incorrectos.";
|
message = "Usuario o contraseña incorrectos.";
|
||||||
} else if (status === 403) {
|
} else if (status === 403) {
|
||||||
message = "Tu cuenta está inactiva o ha sido suspendida.";
|
message = "Tu cuenta está inactiva o suspendida.";
|
||||||
|
} else if (status === 404) {
|
||||||
|
message = "Usuario no encontrado.";
|
||||||
|
} else if (data?.message) {
|
||||||
|
message = data.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (formData) => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const BASE_URL = config.apiConfig.baseUrl;
|
||||||
|
const REGISTER_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.register}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(REGISTER_URL, formData);
|
||||||
|
|
||||||
|
const { token, user, account, metadata } = res.data;
|
||||||
|
|
||||||
|
const identity = {
|
||||||
|
user,
|
||||||
|
account,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
localStorage.setItem("identity", JSON.stringify(identity));
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
setIdentity(identity);
|
||||||
|
setAuthStatus("authenticated");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error al registrarse:", err);
|
||||||
|
|
||||||
|
let message = "Ha ocurrido un error inesperado.";
|
||||||
|
|
||||||
|
if (err.response) {
|
||||||
|
const { status, data } = err.response;
|
||||||
|
|
||||||
|
if (status === 400) {
|
||||||
|
message = "Usuario o contraseña incorrectos.";
|
||||||
|
} else if (status === 403) {
|
||||||
|
message = "Tu cuenta está inactiva o suspendida.";
|
||||||
} else if (status === 404) {
|
} else if (status === 404) {
|
||||||
message = "Usuario no encontrado.";
|
message = "Usuario no encontrado.";
|
||||||
} else if (data?.message) {
|
} else if (data?.message) {
|
||||||
@@ -84,14 +143,28 @@ export const AuthProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.clear();
|
localStorage.removeItem("token");
|
||||||
setUser(null);
|
localStorage.removeItem("identity");
|
||||||
|
setIdentity(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setAuthStatus("unauthenticated");
|
setAuthStatus("unauthenticated");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearError = () => setError(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, token, authStatus, login, logout, error }}>
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
identity, // { user, account, metadata }
|
||||||
|
token,
|
||||||
|
authStatus,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
error,
|
||||||
|
clearError
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -4,8 +4,8 @@ import { useData } from "../hooks/useData";
|
|||||||
|
|
||||||
export const DataContext = createContext();
|
export const DataContext = createContext();
|
||||||
|
|
||||||
export const DataProvider = ({ config, children }) => {
|
export const DataProvider = ({ config, onError, children }) => {
|
||||||
const data = useData(config);
|
const data = useData(config, onError);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataContext.Provider value={data}>
|
<DataContext.Provider value={data}>
|
||||||
40
frontend/src/context/ErrorContext.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createContext, useState, useContext } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import NotificationModal from '@/components/NotificationModal';
|
||||||
|
|
||||||
|
const ErrorContext = createContext();
|
||||||
|
|
||||||
|
export const ErrorProvider = ({ children }) => {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const showError = (err) => {
|
||||||
|
setError({
|
||||||
|
title: err.status ? `Error ${err.status}` : "Error",
|
||||||
|
message: err.message,
|
||||||
|
variant: 'danger'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeError = () => setError(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorContext.Provider value={{ showError }}>
|
||||||
|
{children}
|
||||||
|
{error && (
|
||||||
|
<NotificationModal
|
||||||
|
show={true}
|
||||||
|
onClose={closeError}
|
||||||
|
title={error.title}
|
||||||
|
message={error.message}
|
||||||
|
variant='danger'
|
||||||
|
buttons={[{ label: "Aceptar", variant: "danger", onClick: closeError }]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ErrorContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ErrorProvider.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useError = () => useContext(ErrorContext);
|
||||||
46
frontend/src/css/AnimatedDropdown.css
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
.dropdown-menu .dropdown-divider {
|
||||||
|
border-top: 1px solid var(--dirty-gold);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
background: #0f1114 !important;
|
||||||
|
border: 1px solid var(--dirty-gold) !important;
|
||||||
|
box-shadow: 0 0 20px rgba(0,0,0,0.9);
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
clip-path: polygon(
|
||||||
|
10px 0, 100% 0,
|
||||||
|
100% calc(100% - 10px),
|
||||||
|
calc(100% - 10px) 100%,
|
||||||
|
0 100%, 0 10px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu.show {
|
||||||
|
background: #000 !important;
|
||||||
|
box-shadow: 0 0 25px rgba(138, 11, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--parchment) !important;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: var(--blood-god) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
text-shadow: 0 0 5px #fff;
|
||||||
|
box-shadow: inset 0 0 10px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.disabled,
|
||||||
|
.disabled.text-muted {
|
||||||
|
color: #444 !important;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
12
frontend/src/css/Footer.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
footer {
|
||||||
|
background-color: #020202;
|
||||||
|
border-top: 1px solid var(--dirty-gold);
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skull-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--parchment);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
75
frontend/src/css/Header.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
header {
|
||||||
|
background:
|
||||||
|
linear-gradient(to bottom, rgba(0,0,0,1), rgba(0,0,0,1)),
|
||||||
|
url('https://www.transparenttextures.com/patterns/dark-matter.png');
|
||||||
|
border-bottom: 4px double var(--imperial-gold);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imperial-header {
|
||||||
|
overflow: visible;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purity-seal {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
width: 196px;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purity-seal.left {
|
||||||
|
left: 8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purity-seal.right {
|
||||||
|
right: 5%;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--imperial-gold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 5px;
|
||||||
|
text-shadow: 0 0 15px rgba(198, 163, 77, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--blood-god);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 0 5px var(--blood-god);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
header {
|
||||||
|
padding: 2.5rem 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
frontend/src/css/LoginForm.css
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
.login-card {
|
||||||
|
background: #111 !important;
|
||||||
|
border: 1px solid var(--dirty-gold) !important;
|
||||||
|
color: var(--parchment) !important;
|
||||||
|
padding: 40px;
|
||||||
|
clip-path: polygon(
|
||||||
|
20px 0, 100% 0,
|
||||||
|
100% calc(100% - 20px),
|
||||||
|
calc(100% - 20px) 100%,
|
||||||
|
0 100%, 0 20px
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 25px rgba(0,0,0,0.9);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 6px;
|
||||||
|
border: 1px dashed #222;
|
||||||
|
pointer-events: none;
|
||||||
|
clip-path: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.form-control {
|
||||||
|
background-color: rgba(0,0,0,0.8) !important;
|
||||||
|
border: 1px solid var(--plasteel) !important;
|
||||||
|
color: var(--imperial-gold) !important;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.form-control:focus {
|
||||||
|
background-color: #000 !important;
|
||||||
|
border-color: var(--blood-god) !important;
|
||||||
|
box-shadow: 0 0 10px rgba(138, 11, 11, 0.4) !important;
|
||||||
|
color: var(--imperial-gold) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating > label {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--dirty-gold) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating > label::after {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
font-family: 'Cinzel', serif !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
letter-spacing: 2px !important;
|
||||||
|
background: #000 !important;
|
||||||
|
border: 1px solid var(--imperial-gold) !important;
|
||||||
|
color: var(--imperial-gold) !important;
|
||||||
|
padding: 15px 40px !important;
|
||||||
|
clip-path: polygon(
|
||||||
|
10px 0, 100% 0,
|
||||||
|
100% calc(100% - 10px),
|
||||||
|
calc(100% - 10px) 100%,
|
||||||
|
0 100%, 0 10px
|
||||||
|
);
|
||||||
|
transition: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover {
|
||||||
|
background: var(--blood-god) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--plasma-glow) !important;
|
||||||
|
box-shadow: 0 0 20px var(--blood-god) !important;
|
||||||
|
text-shadow: 0 0 5px #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.login-card {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.login-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
padding: 12px 24px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
frontend/src/css/NavBar.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
.nav-container {
|
||||||
|
background-color: #0f1114;
|
||||||
|
border-bottom: 1px solid var(--dirty-gold);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-custom {
|
||||||
|
display: block;
|
||||||
|
padding: 15px 30px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #666;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: 0.3s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.border-left .nav-link-custom {
|
||||||
|
border-left: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.border-right .nav-link-custom {
|
||||||
|
border-right: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-custom:hover {
|
||||||
|
color: var(--parchment);
|
||||||
|
background-color: var(--blood-god);
|
||||||
|
box-shadow: inset 0 0 10px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-custom::before { content: '[ '; opacity: 0; transition: 0.3s; color: var(--imperial-gold); }
|
||||||
|
.nav-link-custom::after { content: ' ]'; opacity: 0; transition: 0.3s; color: var(--imperial-gold); }
|
||||||
|
.nav-link-custom:hover::before, .nav-link-custom:hover::after { opacity: 1; }
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
all: unset;
|
||||||
|
display: block;
|
||||||
|
padding: 15px 30px;
|
||||||
|
color: #666;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: 0.3s;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-left .nav-button {
|
||||||
|
border-left: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-right .nav-button {
|
||||||
|
border-right: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover {
|
||||||
|
color: var(--parchment);
|
||||||
|
background-color: var(--blood-god);
|
||||||
|
box-shadow: inset 0 0 10px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button::before { content: '[ '; opacity: 0; transition: 0.3s; color: var(--imperial-gold); }
|
||||||
|
.nav-button::after { content: ' ]'; opacity: 0; transition: 0.3s; color: var(--imperial-gold); }
|
||||||
|
|
||||||
|
.nav-button:hover::before,
|
||||||
|
.nav-button:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
67
frontend/src/css/NavBarMobile.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
.nav-container {
|
||||||
|
background-color: #0f1114;
|
||||||
|
border-bottom: 1px solid var(--dirty-gold);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 900;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-mobile-header {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-mobile-brand {
|
||||||
|
color: #666;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-mobile-toggle {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-mobile-toggle span {
|
||||||
|
display: block;
|
||||||
|
width: 25px;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--dirty-gold);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-mobile-toggle.open span:nth-child(1) {
|
||||||
|
transform: rotate(45deg) translate(5px, 5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-mobile-toggle.open span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-mobile-toggle.open span:nth-child(3) {
|
||||||
|
transform: rotate(-45deg) translate(5px, -5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list-mobile {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list-mobile li {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list-mobile .nav-link-custom,
|
||||||
|
.nav-list-mobile .nav-button {
|
||||||
|
padding: 12px 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ button.show-button {
|
|||||||
color: var(--show-btn-color);
|
color: var(--show-btn-color);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.show-button:hover {
|
button.show-button:hover {
|
||||||
color: var(--show-btn-hover);
|
color: var(--show-btn-hover);
|
||||||
}
|
}
|
||||||
47
frontend/src/css/TechCard.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
.tech-card {
|
||||||
|
background: #111 !important;
|
||||||
|
border: 1px solid var(--dirty-gold);
|
||||||
|
padding: 40px;
|
||||||
|
position: relative;
|
||||||
|
clip-path: polygon(
|
||||||
|
20px 0, 100% 0,
|
||||||
|
100% calc(100% - 20px), calc(100% - 20px) 100%,
|
||||||
|
0 100%, 0 20px
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 15px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 5px; left: 5px; right: 5px; bottom: 5px;
|
||||||
|
border: 1px dashed #333;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
clip-path: polygon(
|
||||||
|
20px 0, 100% 0,
|
||||||
|
100% calc(100% - 20px), calc(100% - 20px) 100%,
|
||||||
|
0 100%, 0 20px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card p {
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.tech-card {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.tech-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
308
frontend/src/css/index.css
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Cinzel';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(https://fonts.gstatic.com/s/cinzel/v26/8vIJ7ww63mVu7gt7-GT7LEc.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Cinzel';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(https://fonts.gstatic.com/s/cinzel/v26/8vIJ7ww63mVu7gt79mT7.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Cinzel';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(https://fonts.gstatic.com/s/cinzel/v26/8vIJ7ww63mVu7gt7-GT7LEc.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Cinzel';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(https://fonts.gstatic.com/s/cinzel/v26/8vIJ7ww63mVu7gt79mT7.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Cinzel';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(https://fonts.gstatic.com/s/cinzel/v26/8vIJ7ww63mVu7gt7-GT7LEc.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Cinzel';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(https://fonts.gstatic.com/s/cinzel/v26/8vIJ7ww63mVu7gt79mT7.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Share Tech Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(https://fonts.gstatic.com/s/sharetechmono/v16/J7aHnp1uDWRBEqV98dVQztYldFcLowEF.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--void-black: #050505;
|
||||||
|
--blood-god: #8a0b0b;
|
||||||
|
--plasma-glow: #ff3333;
|
||||||
|
--imperial-gold: #c6a34d;
|
||||||
|
--dirty-gold: #8c7335;
|
||||||
|
--plasteel: #2f353b;
|
||||||
|
--mechanicus-green: #23382c;
|
||||||
|
--parchment: #c2bbad;
|
||||||
|
--crt-line: rgba(18, 16, 16, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
background-color: var(--void-black);
|
||||||
|
color: var(--parchment);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRT */
|
||||||
|
body::after {
|
||||||
|
content: " ";
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
|
||||||
|
z-index: 1000;
|
||||||
|
background-size: 100% 2px, 3px 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SECCIONES Y TITULOS */
|
||||||
|
section {
|
||||||
|
padding: 80px 20px !important;
|
||||||
|
max-width: 1200px !important;
|
||||||
|
margin: auto !important;
|
||||||
|
scroll-margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--parchment);
|
||||||
|
text-transform: uppercase;
|
||||||
|
position: relative;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before, .section-title::after {
|
||||||
|
content: "+++";
|
||||||
|
color: var(--dirty-gold);
|
||||||
|
margin: 0 15px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SPIN */
|
||||||
|
.spin-background {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin-background img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: 0.15;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BOTONES */
|
||||||
|
.btn-imperial {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 15px 40px;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid var(--imperial-gold);
|
||||||
|
color: var(--imperial-gold);
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.4s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-imperial:hover {
|
||||||
|
background: var(--blood-god);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--plasma-glow);
|
||||||
|
box-shadow: 0 0 20px var(--blood-god);
|
||||||
|
text-shadow: 0 0 5px #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FOTOS */
|
||||||
|
.foto-frame {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid var(--plasteel);
|
||||||
|
padding: 5px;
|
||||||
|
background: #000;
|
||||||
|
transition: 0.3s;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foto-frame:hover {
|
||||||
|
border-color: var(--imperial-gold);
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foto-placeholder {
|
||||||
|
height: 300px;
|
||||||
|
background: radial-gradient(circle, #222, #000);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #444;
|
||||||
|
border: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foto-placeholder span {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foto-caption {
|
||||||
|
background: var(--plasteel);
|
||||||
|
color: #aaa;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FORMULARIO */
|
||||||
|
.tech-input, .tech-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: rgba(0,0,0,0.7);
|
||||||
|
border: 1px solid var(--plasteel);
|
||||||
|
color: var(--imperial-gold);
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-input:focus, .tech-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--blood-god);
|
||||||
|
box-shadow: 0 0 10px rgba(138, 11, 11, 0.3);
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes textFlicker {
|
||||||
|
0%, 95%, 97%, 99%, 100% { opacity: 1; }
|
||||||
|
96% { opacity: 0.8; }
|
||||||
|
98% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 { animation: textFlicker 5s infinite; }
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
section {
|
||||||
|
padding: 60px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before,
|
||||||
|
.section-title::after {
|
||||||
|
margin: 0 10px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foto-placeholder {
|
||||||
|
height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-imperial {
|
||||||
|
padding: 12px 28px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-input,
|
||||||
|
.tech-textarea {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
section {
|
||||||
|
padding: 40px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before,
|
||||||
|
.section-title::after {
|
||||||
|
margin: 0 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foto-placeholder {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foto-placeholder span {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foto-caption {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-imperial {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { AuthContext } from "@/context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
|
|
||||||
export const useAuth = () => useContext(AuthContext);
|
export const useAuth = () => useContext(AuthContext);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { ConfigContext } from "@/context/ConfigContext.jsx";
|
import { ConfigContext } from "../context/ConfigContext.jsx";
|
||||||
|
|
||||||
export const useConfig = () => useContext(ConfigContext);
|
export const useConfig = () => useContext(ConfigContext);
|
||||||
140
frontend/src/hooks/useData.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const useData = (config, onError) => {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [dataLoading, setLoading] = useState(true);
|
||||||
|
const [dataError, setError] = useState(null);
|
||||||
|
const configRef = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.baseUrl) {
|
||||||
|
configRef.current = config;
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const getAuthHeaders = (isFormData = false) => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
if (!isFormData) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAxiosError = (err) => {
|
||||||
|
if (err.response && err.response.data) {
|
||||||
|
const data = err.response.data;
|
||||||
|
|
||||||
|
if (data.status === 422 && data.errors) {
|
||||||
|
return {
|
||||||
|
status: 422,
|
||||||
|
errors: data.errors,
|
||||||
|
path: data.path ?? null,
|
||||||
|
timestamp: data.timestamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: data.status ?? err.response.status,
|
||||||
|
error: data.error ?? null,
|
||||||
|
message: data.message ?? err.response.statusText ?? "Error desconocido",
|
||||||
|
path: data.path ?? null,
|
||||||
|
timestamp: data.timestamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.request) {
|
||||||
|
return {
|
||||||
|
status: null,
|
||||||
|
error: "Network Error",
|
||||||
|
message: "No se pudo conectar al servidor",
|
||||||
|
path: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: null,
|
||||||
|
error: "Client Error",
|
||||||
|
message: err.message || "Error desconocido",
|
||||||
|
path: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
const current = configRef.current;
|
||||||
|
if (!current?.baseUrl) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(current.baseUrl, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
params: current.params,
|
||||||
|
});
|
||||||
|
setData(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
const error = handleAxiosError(err);
|
||||||
|
setError(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.baseUrl) fetchData();
|
||||||
|
}, [config, fetchData]);
|
||||||
|
|
||||||
|
const requestWrapper = async (method, endpoint, payload = null, refresh = false) => {
|
||||||
|
try {
|
||||||
|
const isFormData = payload instanceof FormData;
|
||||||
|
const headers = getAuthHeaders(isFormData);
|
||||||
|
const cfg = { headers };
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (method === "get") {
|
||||||
|
if (payload) cfg.params = payload;
|
||||||
|
response = await axios.get(endpoint, cfg);
|
||||||
|
} else if (method === "delete") {
|
||||||
|
if (payload) cfg.data = payload;
|
||||||
|
response = await axios.delete(endpoint, cfg);
|
||||||
|
} else {
|
||||||
|
response = await axios[method](endpoint, payload, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refresh) await fetchData();
|
||||||
|
return response.data;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const error = handleAxiosError(err);
|
||||||
|
|
||||||
|
if (error.status !== 422 && onError) {
|
||||||
|
onError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearError = () => setError(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
dataLoading,
|
||||||
|
dataError,
|
||||||
|
clearError,
|
||||||
|
getData: (url, params, refresh = true) => requestWrapper("get", url, params, refresh),
|
||||||
|
postData: (url, body, refresh = true) => requestWrapper("post", url, body, refresh),
|
||||||
|
putData: (url, body, refresh = true) => requestWrapper("put", url, body, refresh),
|
||||||
|
deleteData: (url, refresh = true) => requestWrapper("delete", url, null, refresh),
|
||||||
|
deleteDataWithBody: (url, body, refresh = true) => requestWrapper("delete", url, body, refresh)
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { DataContext } from "@/context/DataContext";
|
import { DataContext } from "../context/DataContext";
|
||||||
|
|
||||||
export const useDataContext = () => useContext(DataContext);
|
export const useDataContext = () => useContext(DataContext);
|
||||||
13
frontend/src/hooks/useWindowWidth.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export const useWindowWidth = () => {
|
||||||
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => setWidth(window.innerWidth);
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
};
|
||||||
19
frontend/src/i18n.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
|
import en from "@/locales/en.json";
|
||||||
|
import es from "@/locales/es.json";
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
en: { translation: en },
|
||||||
|
es: { translation: es },
|
||||||
|
},
|
||||||
|
lng: "en",
|
||||||
|
fallbackLng: "es",
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
60
frontend/src/locales/en.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"subtitle": "Markcus Garklios The Executioner Crusader"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"inicio": { "label": "Home", "href": "home" },
|
||||||
|
"muestras": { "label": "Portfolio", "href": "portfolio" },
|
||||||
|
"pedidos": { "label": "Comissions", "href": "comissions" },
|
||||||
|
"sobre": { "label": "About", "href": "about-me" },
|
||||||
|
"login": { "label": "Login", "href": "login" }
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"inicio_title": "+++ INITIALIZING +++",
|
||||||
|
"inicio_thought": "// THOUGHT OF THE DAY: HOPE IS THE FIRST STEP TOWARD DISAPPOINTMENT.",
|
||||||
|
"inicio_welcome": "Welcome to Markcus' personal manufactorum. Here, grey miniatures are purged of their lack of color and blessed with sacred pigments, Nuln Oil washes, and ritual dry brushing.",
|
||||||
|
"inicio_note": "We don't paint toys. We forge veterans of the Long War.",
|
||||||
|
"inicio_button": "Start Order Protocol",
|
||||||
|
"muestras_title": "+++ Portfolio +++",
|
||||||
|
"muestras": [
|
||||||
|
{
|
||||||
|
"icon": "☠",
|
||||||
|
"title": "Sample A-1: Astartes Pattern"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "⚙",
|
||||||
|
"title": "Sample B-2: Engine War"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "⚔",
|
||||||
|
"title": "Sample C-3: Xenos Filth"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pedidos_title": "+++ Comission +++",
|
||||||
|
"pedidos_text": "Fill in the details to request the attention of the Artificer. Be precise, time is an Emperor-limited resource.",
|
||||||
|
"pedidos_name": "Commander Designation (Name)",
|
||||||
|
"pedidos_email": "Vox Frequency (Email)",
|
||||||
|
"pedidos_requirements": "Detail mission requirements: Color scheme, Faction, Shading level...",
|
||||||
|
"pedidos_button": "Transmit",
|
||||||
|
"sobre_title": "+++ File: Markcus +++",
|
||||||
|
"sobre_status": {
|
||||||
|
"k": "[STATUS]:",
|
||||||
|
"v": "Operational"
|
||||||
|
},
|
||||||
|
"sobre_location": {
|
||||||
|
"k": "[LOCATION]:",
|
||||||
|
"v": "Andalusia"
|
||||||
|
},
|
||||||
|
"sobre_specialty": {
|
||||||
|
"k": "[SPECIALTY]:",
|
||||||
|
"v": "Acrilic painting, realistic details"
|
||||||
|
},
|
||||||
|
"sobre_text": "I´m Markcus, a loyal servant of the Emperor. My purpose is to expand the Emperor's domains, through wargaming and miniature modeling. Warhammer and miniature painting are my hobby and passion. That's why I put my skills as a painter at the service of all of you who want to increase your collection, even if you're a veteran or a beginner. I can paint anything: Space Marines, Imperial Guard, Necrons, Orks, etc. I can also paint miniatures that aren't from Warhammer."
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Authentication required",
|
||||||
|
"user_label": "Identifier",
|
||||||
|
"password_label": "Key",
|
||||||
|
"button": "Proceed"
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/src/locales/es.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"subtitle": "Markcus Garklios The Executioner Crusader"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"inicio": { "label": "Inicio", "href": "inicio" },
|
||||||
|
"muestras": { "label": "Portafolio", "href": "portafolio" },
|
||||||
|
"pedidos": { "label": "Comisiones", "href": "comisiones" },
|
||||||
|
"sobre": { "label": "Sobre Markcus", "href": "sobre-mi" },
|
||||||
|
"login": { "label": "Login", "href": "login" }
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"inicio_title": "+++ INICIALIZANDO +++",
|
||||||
|
"inicio_thought": "// PENSAMIENTO DEL DÍA: LA ESPERANZA ES EL PRIMER PASO HACIA LA DECEPCIÓN.",
|
||||||
|
"inicio_welcome": "Bienvenido al manufactorum personal de Markcus. Aquí, las miniaturas grises son purgadas de su falta de color y bendecidas con pigmentos sagrados, lavados de Nuln Oil y pincel seco ritual.",
|
||||||
|
"inicio_note": "No pintamos juguetes. Forjamos veteranos de la Larga Guerra.",
|
||||||
|
"inicio_button": "Iniciar Protocolo de Encargo",
|
||||||
|
|
||||||
|
"muestras_title": "+++ Portafolio +++",
|
||||||
|
"muestras": [
|
||||||
|
{ "icon": "☠", "title": "Muestra A-1: Astartes Pattern" },
|
||||||
|
{ "icon": "⚙", "title": "Muestra B-2: Engine War" },
|
||||||
|
{ "icon": "⚔", "title": "Muestra C-3: Xenos Filth" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"pedidos_title": "+++ Comisión +++",
|
||||||
|
"pedidos_text": "Rellena los datos para solicitar la atención del Artífice. Sé preciso, el tiempo es un recurso limitado del Emperador.",
|
||||||
|
"pedidos_name": "Designación del Comandante (Nombre)",
|
||||||
|
"pedidos_email": "Frecuencia Vox (Email)",
|
||||||
|
"pedidos_requirements": "Detalla los requerimientos de la misión: Esquema de color, Facción, Nivel de degradado...",
|
||||||
|
"pedidos_button": "Transmitir",
|
||||||
|
|
||||||
|
"sobre_title": "+++ Archivo: Markcus +++",
|
||||||
|
"sobre_status": {
|
||||||
|
"k": "[ESTADO]:",
|
||||||
|
"v": "Operativo"
|
||||||
|
},
|
||||||
|
"sobre_location": {
|
||||||
|
"k": "[UBICACIÓN]:",
|
||||||
|
"v": "Andalucía"
|
||||||
|
},
|
||||||
|
"sobre_specialty": {
|
||||||
|
"k": "[ESPECIALIDAD]:",
|
||||||
|
"v": "Pintura acrílica, detalles realistas"
|
||||||
|
},
|
||||||
|
"sobre_text": "Soy Markcus, un leal servidor del Emperador. Mi propósito es expandir los dominios del Emperador, a través de wargames y modelado de miniaturas. Warhammer y la pintura de miniaturas son mi afición y pasión. Por eso pongo mis habilidades como pintor al servicio de todos vosotros que queréis ampliar vuestra colección, ya seáis veteranos o principiantes. Puedo pintar cualquier cosa: Marines Espaciales, Guardia Imperial, Necrones, Orkos, etc. También puedo pintar miniaturas que no sean de Warhammer."
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Autenticación requerida",
|
||||||
|
"user_label": "Identificador",
|
||||||
|
"password_label": "Clave",
|
||||||
|
"button": "Acceder"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { createRoot } from 'react-dom/client'
|
|||||||
/* COMPONENTS */
|
/* COMPONENTS */
|
||||||
import App from '@/App.jsx'
|
import App from '@/App.jsx'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { ThemeProvider } from '@/context/ThemeContext'
|
|
||||||
import { AuthProvider } from '@/context/AuthContext'
|
import { AuthProvider } from '@/context/AuthContext'
|
||||||
import { ConfigProvider } from '@/context/ConfigContext.jsx'
|
import { ConfigProvider } from '@/context/ConfigContext.jsx'
|
||||||
|
|
||||||
@@ -18,13 +17,11 @@ import '@/css/index.css'
|
|||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<ThemeProvider>
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
96
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import ContentWrapper from "@/components/ContentWrapper";
|
||||||
|
import CustomContainer from "@/components/CustomContainer";
|
||||||
|
import ScrollBackgroundSpin from "@/components/Home/ScrollBackgroundSpin";
|
||||||
|
import TechCard from "@/components/TechCard";
|
||||||
|
import { HashLink } from "react-router-hash-link/dist/react-router-hash-link.cjs.production";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const muestras = t("home.muestras", { returnObjects: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentWrapper>
|
||||||
|
<ScrollBackgroundSpin />
|
||||||
|
<CustomContainer>
|
||||||
|
|
||||||
|
<section id={t("nav.inicio.href")} className="container py-5">
|
||||||
|
<h2 className="section-title text-center mb-5">{t("home.inicio_title")}</h2>
|
||||||
|
<TechCard>
|
||||||
|
<p style={{ color: 'var(--imperial-gold)', fontSize: '0.9rem' }}>
|
||||||
|
{t("home.inicio_thought")}
|
||||||
|
</p>
|
||||||
|
<hr style={{ border: 0, borderTop: '1px solid #333', margin: '20px 0' }} />
|
||||||
|
<p className="mb-4">
|
||||||
|
{t("home.inicio_welcome")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">{t("home.inicio_note")}</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<HashLink smooth to={"/#pedidos"} className="btn-imperial">
|
||||||
|
{t("home.inicio_button")}
|
||||||
|
</HashLink>
|
||||||
|
</div>
|
||||||
|
</TechCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id={t("nav.muestras.href")} className="container py-5">
|
||||||
|
<h2 className="section-title text-center mb-5">{t("home.muestras_title")}</h2>
|
||||||
|
<div className="row g-4">
|
||||||
|
{muestras.map((m, i) => (
|
||||||
|
<div className="col-md-4" key={i}>
|
||||||
|
<div className="foto-frame d-flex flex-column">
|
||||||
|
<div className="foto-placeholder flex-grow-1">
|
||||||
|
<span>{m.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div className="foto-caption">
|
||||||
|
{m.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id={t("nav.pedidos.href")} className="container py-5">
|
||||||
|
<h2 className="section-title text-center mb-5">{t("home.pedidos_title")}</h2>
|
||||||
|
<TechCard>
|
||||||
|
<p className="mb-4">{t("home.pedidos_text")}</p>
|
||||||
|
<form>
|
||||||
|
<div className="mb-3">
|
||||||
|
<input type="text" className="tech-input" placeholder={t("home.pedidos_name")} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<input type="email" className="tech-input" placeholder={t("home.pedidos_email")} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<textarea className="tech-textarea" rows="6" placeholder={t("home.pedidos_requirements")}></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn-imperial border-0">{t("home.pedidos_button")}</button>
|
||||||
|
</form>
|
||||||
|
</TechCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id={t("nav.sobre.href")} className="container py-5">
|
||||||
|
<h2 className="section-title text-center mb-5">{t("home.sobre_title")}</h2>
|
||||||
|
<TechCard>
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-12">
|
||||||
|
<p>
|
||||||
|
<strong>{t("home.sobre_status.k")}</strong> {t("home.sobre_status.v")}<br />
|
||||||
|
<strong>{t("home.sobre_location.k")}</strong> {t("home.sobre_location.v")}<br />
|
||||||
|
<strong>{t("home.sobre_specialty.k")}</strong> {t("home.sobre_specialty.v")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 m-0">
|
||||||
|
{t("home.sobre_text")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TechCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</CustomContainer>
|
||||||
|
</ContentWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
9
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import LoginForm from "@/components/Auth/LoginForm";
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
return (
|
||||||
|
<LoginForm />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
5
frontend/src/util/array.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const random = (arr) => {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export { random }
|
||||||
|
Before Width: | Height: | Size: 340 KiB |
28
src/App.jsx
@@ -1,28 +0,0 @@
|
|||||||
import Header from '@/components/Header.jsx';
|
|
||||||
import NavBar from '@/components/NavBar.jsx';
|
|
||||||
import Footer from '@/components/Footer.jsx';
|
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
|
||||||
import ProtectedRoute from '@/components/Auth/ProtectedRoute.jsx'
|
|
||||||
import useSessionRenewal from '@/hooks/useSessionRenewal'
|
|
||||||
import { CONSTANTS } from '@/util/constants'
|
|
||||||
|
|
||||||
import Home from '@/pages/Home.jsx'
|
|
||||||
import Building from '@/pages/Building.jsx'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const routesWithFooter = ["/"];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header />
|
|
||||||
<NavBar />
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Home />} />
|
|
||||||
<Route path="/*" element={<Building />} />
|
|
||||||
</Routes>
|
|
||||||
{routesWithFooter.includes(useLocation().pathname) ? <Footer /> : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, cloneElement } from 'react';
|
|
||||||
import { Button } from 'react-bootstrap';
|
|
||||||
import { AnimatePresence, motion as _motion } from 'framer-motion';
|
|
||||||
import '@/css/AnimatedDropdown.css';
|
|
||||||
|
|
||||||
const AnimatedDropend = ({
|
|
||||||
trigger,
|
|
||||||
icon,
|
|
||||||
variant = "secondary",
|
|
||||||
className = "",
|
|
||||||
buttonStyle = "",
|
|
||||||
show,
|
|
||||||
onToggle,
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseLeave,
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const isControlled = show !== undefined;
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const triggerRef = useRef(null);
|
|
||||||
const dropdownRef = useRef(null);
|
|
||||||
|
|
||||||
const actualOpen = isControlled ? show : open;
|
|
||||||
|
|
||||||
const toggle = (forceValue) => {
|
|
||||||
const newState = typeof forceValue === "boolean" ? forceValue : !actualOpen;
|
|
||||||
if (!isControlled) setOpen(newState);
|
|
||||||
onToggle?.(newState);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e) => {
|
|
||||||
if (
|
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(e.target) &&
|
|
||||||
!triggerRef.current?.contains(e.target)
|
|
||||||
) {
|
|
||||||
if (!isControlled) setOpen(false);
|
|
||||||
onToggle?.(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [isControlled, onToggle]);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
if (!isControlled) setOpen(true);
|
|
||||||
onToggle?.(true);
|
|
||||||
onMouseEnter?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
if (!isControlled) setOpen(false);
|
|
||||||
onToggle?.(false);
|
|
||||||
onMouseLeave?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerElement = trigger
|
|
||||||
? (typeof trigger === "function"
|
|
||||||
? trigger({
|
|
||||||
onClick: e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggle();
|
|
||||||
},
|
|
||||||
ref: triggerRef
|
|
||||||
})
|
|
||||||
: cloneElement(trigger, {
|
|
||||||
onClick: e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggle();
|
|
||||||
},
|
|
||||||
ref: triggerRef
|
|
||||||
}))
|
|
||||||
: (
|
|
||||||
<Button
|
|
||||||
ref={triggerRef}
|
|
||||||
variant={variant}
|
|
||||||
className={`circle-btn ${buttonStyle}`}
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="position-relative d-inline-block dropend"
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
ref={triggerRef}
|
|
||||||
>
|
|
||||||
{triggerElement}
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{actualOpen && (
|
|
||||||
<_motion.div
|
|
||||||
ref={dropdownRef}
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -10 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className={dropdownClasses}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '0',
|
|
||||||
left: '100%',
|
|
||||||
zIndex: 1000,
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
|
|
||||||
</_motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnimatedDropend;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import '@/css/Footer.css';
|
|
||||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
|
||||||
|
|
||||||
const Footer = () => {
|
|
||||||
const [heart, setHeart] = useState('💜');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hearts = ["❤️", "💛", "🧡", "💚", "💙", "💜"];
|
|
||||||
const randomHeart = () => hearts[Math.floor(Math.random() * hearts.length)];
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setHeart(randomHeart());
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer d-flex flex-column align-items-center gap-5 pt-5 px-4">
|
|
||||||
<div className="footer-columns w-100" style={{ maxWidth: '900px' }}>
|
|
||||||
<div className="footer-column">
|
|
||||||
<h4 className="footer-title">Contenido del footer</h4>
|
|
||||||
<div className="contact-info p-4">
|
|
||||||
<a
|
|
||||||
href="https://github.com/Gallardo7761"
|
|
||||||
target="_blank"
|
|
||||||
className='text-break d-block'
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faGithub} className="fa-icon me-2 " />
|
|
||||||
Gallardo7761
|
|
||||||
</a>
|
|
||||||
<a href="mailto:jose@miarma.net" className="text-break d-block">
|
|
||||||
<FontAwesomeIcon icon={faEnvelope} className="fa-icon me-2" />
|
|
||||||
jose@miarma.net
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="footer-bottom w-100 py-5 text-center">
|
|
||||||
<h6 id="devd" className='m-0'>
|
|
||||||
Hecho con <span className="heart-anim">{heart}</span> por{' '}
|
|
||||||
<a href="https://gallardo.dev" target="_blank" rel="noopener noreferrer">
|
|
||||||
Gallardo7761
|
|
||||||
</a>
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Footer;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import '@/css/Header.css';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const Header = () => {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className={`text-center bg-img`}>
|
|
||||||
<div className="m-0 p-5 mask">
|
|
||||||
<div className="d-flex flex-column justify-content-center align-items-center h-100">
|
|
||||||
<Link to='/' className='text-decoration-none'>
|
|
||||||
<h1 className='header-title m-0 text-white shadowed'>Tu página web</h1>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import {
|
|
||||||
faSignIn,
|
|
||||||
faUser,
|
|
||||||
faSignOut,
|
|
||||||
faHouse,
|
|
||||||
faList,
|
|
||||||
faBullhorn,
|
|
||||||
faFile
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
|
|
||||||
import '@/css/NavBar.css';
|
|
||||||
|
|
||||||
import ThemeButton from '@/components/ThemeButton.jsx';
|
|
||||||
|
|
||||||
import IfAuthenticated from '@/components/Auth/IfAuthenticated.jsx';
|
|
||||||
import IfNotAuthenticated from '@/components/Auth/IfNotAuthenticated.jsx';
|
|
||||||
import IfRole from '@/components/Auth/IfRole.jsx';
|
|
||||||
|
|
||||||
import { Navbar, Nav, Container } from 'react-bootstrap';
|
|
||||||
import AnimatedDropdown from '@/components/AnimatedDropdown.jsx';
|
|
||||||
|
|
||||||
import { CONSTANTS } from '@/util/constants.js';
|
|
||||||
|
|
||||||
const NavBar = () => {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const [showingUserDropdown, setShowingUserDropdown] = useState(false);
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [isLg, setIsLg] = useState(window.innerWidth >= 992);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
setIsLg(window.innerWidth >= 992 && window.innerWidth < 1200);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleResize(); // inicializar
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (window.innerWidth >= 992) {
|
|
||||||
setExpanded(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Navbar expand="lg" sticky="top" expanded={expanded} onToggle={() => setExpanded(!expanded)}>
|
|
||||||
<Container fluid>
|
|
||||||
<Navbar.Toggle aria-controls="navbar" className="custom-toggler">
|
|
||||||
<svg width="30" height="30" viewBox="0 0 30 30">
|
|
||||||
<path
|
|
||||||
d="M4 7h22M4 15h22M4 23h22"
|
|
||||||
stroke="var(--navbar-link-color)"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeMiterlimit="10"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Navbar.Toggle>
|
|
||||||
|
|
||||||
<Navbar.Collapse id="main-navbar">
|
|
||||||
<Nav className="me-auto gap-2">
|
|
||||||
<Nav.Link
|
|
||||||
as={Link}
|
|
||||||
to="/"
|
|
||||||
title="Inicio"
|
|
||||||
href="/"
|
|
||||||
className={`text-truncate ${expanded ? "mt-3" : ""}`}
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faHouse} className="me-2" />
|
|
||||||
Inicio
|
|
||||||
</Nav.Link>
|
|
||||||
|
|
||||||
<div className="d-lg-none mt-2 ms-2">
|
|
||||||
<ThemeButton onlyIcon={isLg} />
|
|
||||||
</div>
|
|
||||||
</Nav>
|
|
||||||
</Navbar.Collapse>
|
|
||||||
|
|
||||||
<div className="d-none d-lg-block me-3">
|
|
||||||
<ThemeButton onlyIcon={isLg} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Nav className="d-flex flex-md-row flex-column gap-2 ms-auto align-items-center">
|
|
||||||
<IfAuthenticated>
|
|
||||||
<AnimatedDropdown
|
|
||||||
className='end-0 position-absolute'
|
|
||||||
show={showingUserDropdown}
|
|
||||||
onMouseEnter={() => setShowingUserDropdown(true)}
|
|
||||||
onMouseLeave={() => setShowingUserDropdown(false)}
|
|
||||||
onToggle={(isOpen) => setShowingUserDropdown(isOpen)}
|
|
||||||
trigger={
|
|
||||||
<Link className="nav-link dropdown-toggle fw-bold">
|
|
||||||
@{user?.user_name}
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Link to="/perfil" className="text-muted dropdown-item nav-link">
|
|
||||||
<FontAwesomeIcon icon={faUser} className="me-2" />
|
|
||||||
Mi perfil
|
|
||||||
</Link>
|
|
||||||
<hr className="dropdown-divider" />
|
|
||||||
<Link to="#" className="dropdown-item nav-link" onClick={logout}>
|
|
||||||
<FontAwesomeIcon icon={faSignOut} className="me-2" />
|
|
||||||
Cerrar sesión
|
|
||||||
</Link>
|
|
||||||
</AnimatedDropdown>
|
|
||||||
</IfAuthenticated>
|
|
||||||
|
|
||||||
<IfNotAuthenticated>
|
|
||||||
<Nav.Link as={Link} to="/login" title="Iniciar sesión">
|
|
||||||
<FontAwesomeIcon icon={faSignIn} className="me-2" />
|
|
||||||
Iniciar sesión
|
|
||||||
</Nav.Link>
|
|
||||||
</IfNotAuthenticated>
|
|
||||||
</Nav>
|
|
||||||
</Container>
|
|
||||||
</Navbar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavBar;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { faFilter, faFilePdf, faPlus } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import AnimatedDropdown from '@components/AnimatedDropdown';
|
|
||||||
import Button from 'react-bootstrap/Button';
|
|
||||||
import { CONSTANTS } from '@/util/constants';
|
|
||||||
import IfRole from '@/components/Auth/IfRole';
|
|
||||||
|
|
||||||
const SearchToolbar = ({ searchTerm, onSearchChange, filtersComponent, onCreate, onPDF }) => (
|
|
||||||
<div className="sticky-toolbar search-toolbar-wrapper">
|
|
||||||
<div className="search-toolbar">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="search-input"
|
|
||||||
placeholder="Buscar..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="toolbar-buttons">
|
|
||||||
{filtersComponent && (
|
|
||||||
<AnimatedDropdown variant="transparent" icon={<FontAwesomeIcon icon={faFilter} className='fa-md' />}>
|
|
||||||
{filtersComponent}
|
|
||||||
</AnimatedDropdown>
|
|
||||||
)}
|
|
||||||
{onPDF && (
|
|
||||||
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
|
||||||
<Button variant="transparent" onClick={onPDF}>
|
|
||||||
<FontAwesomeIcon icon={faFilePdf} className='fa-md' />
|
|
||||||
</Button>
|
|
||||||
</IfRole>
|
|
||||||
)}
|
|
||||||
{onCreate && (
|
|
||||||
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
|
||||||
<Button variant="transparent" onClick={onCreate}>
|
|
||||||
<FontAwesomeIcon icon={faPlus} className='fa-md' />
|
|
||||||
</Button>
|
|
||||||
</IfRole>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default SearchToolbar;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useTheme } from "@/hooks/useTheme.js";
|
|
||||||
import "@/css/ThemeButton.css";
|
|
||||||
|
|
||||||
export default function ThemeButton({ className, onlyIcon}) {
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button className={`theme-toggle ${className}`} onClick={toggleTheme}>
|
|
||||||
{
|
|
||||||
onlyIcon ? (
|
|
||||||
theme === "dark" ? ("🌞") : ("🌙")
|
|
||||||
) : (
|
|
||||||
theme === "dark" ? ("🌞 tema claro") : ("🌙 tema oscuro")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { createContext, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export const ThemeContext = createContext();
|
|
||||||
|
|
||||||
export const ThemeProvider = ({ children }) => {
|
|
||||||
const [theme, setTheme] = useState(() => {
|
|
||||||
return (
|
|
||||||
localStorage.getItem("theme") ||
|
|
||||||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
document.body.classList.remove("light", "dark");
|
|
||||||
document.body.classList.add(theme);
|
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
root.classList.add(theme);
|
|
||||||
localStorage.setItem("theme", theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
.dropdown-menu .dropdown-divider {
|
|
||||||
border-top: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
background-color: var(--bg-color) !important;
|
|
||||||
color: var(--text-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu.show {
|
|
||||||
background-color: var(--navbar-bg) !important;
|
|
||||||
box-shadow: 0 5px 10px var(--shadow-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
background-color: var(--navbar-bg) !important;
|
|
||||||
color: var(--navbar-dropdown-item-color);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: var(--navbar-bg) !important;
|
|
||||||
color: var(--secondary-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disabled.text-muted {
|
|
||||||
color: var(--muted-color) !important;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/* ================================
|
|
||||||
BUILDING COMPONENT - VISUAL ONLY
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.building-container {
|
|
||||||
font-family: 'Product Sans', sans-serif;
|
|
||||||
color: var(--fg-color);
|
|
||||||
animation: fadeInScale 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-icon {
|
|
||||||
font-size: 4rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
animation: bounce 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-title {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.building-subtitle {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animaciones */
|
|
||||||
@keyframes fadeInScale {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounce {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0px);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
.footer {
|
|
||||||
background-color: var(--navbar-bg); /* más similar al navbar */
|
|
||||||
color: var(--fg-color);
|
|
||||||
border-top: 3px solid var(--border-color);
|
|
||||||
font-size: 1rem;
|
|
||||||
box-shadow: 0 -2px 8px var(--shadow-color);
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 12px;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
opacity: 0.25;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-title,
|
|
||||||
.footer h6#devd {
|
|
||||||
font-family: "Product Sans";
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-columns {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
background-color: var(--bg-hover-color); /* sutil contraste */
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.footer-columns {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column h5 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--fg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column ul li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column ul li a {
|
|
||||||
color: var(--fg-color);
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column ul li a:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
text-shadow: 0 0 4px currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-bottom {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-bottom a {
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--fg-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-bottom a:hover {
|
|
||||||
text-shadow: 0 0 5px currentColor;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
background-color: var(--contact-info-bg);
|
|
||||||
color: var(--primary-color);
|
|
||||||
box-shadow: 0 4px 10px var(--shadow-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info a {
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--primary-color);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info a:hover {
|
|
||||||
transform: translateX(8px);
|
|
||||||
color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info .fa-icon {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-anim {
|
|
||||||
display: inline-block;
|
|
||||||
animation: heartbeat 1.5s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes heartbeat {
|
|
||||||
0%, 100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/* ================================
|
|
||||||
HEADER - ESTILO BASE
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.bg-img {
|
|
||||||
background-image: url('/images/bg.png');
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*.mask {
|
|
||||||
background-color: var(--header-mask-color);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
-webkit-backdrop-filter: blur(4px);
|
|
||||||
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-family: 'Product Sans';
|
|
||||||
font-size: 3em;
|
|
||||||
font-weight: bolder;
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadowed {
|
|
||||||
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
RESPONSIVE HEADER TITLE
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.header-title {
|
|
||||||
font-size: 2em;
|
|
||||||
padding: 0 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/* ================================
|
|
||||||
LOGIN - CARD CONTAINER (VISUAL)
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
background-color: var(--login-bg) !important;
|
|
||||||
color: var(--text-color);
|
|
||||||
box-shadow: 0 0 10px var(--shadow-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
INPUTS VISUALES
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
input.form-control {
|
|
||||||
background-color: var(--input-bg);
|
|
||||||
color: var(--input-text);
|
|
||||||
border: 1px solid var(--input-border);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
LABELS PERSONALIZADAS
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.form-floating>label {
|
|
||||||
font-family: 'Product Sans';
|
|
||||||
font-size: 1.1em;
|
|
||||||
color: var(--label-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-floating>label::after {
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
BOTÓN VISUAL
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
font-family: 'Product Sans' !important;
|
|
||||||
font-size: 1.3em !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
background-color: var(--login-btn-bg) !important;
|
|
||||||
color: var(--login-btn-text) !important;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:hover {
|
|
||||||
background-color: var(--login-btn-hover) !important;
|
|
||||||
color: var(--login-btn-text-hover) !important;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/* ================================
|
|
||||||
NAVBAR - VISUAL + THEMING ONLY
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
background-color: var(--navbar-bg) !important;
|
|
||||||
box-shadow: var(--navbar-shadow);
|
|
||||||
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
color: var(--navbar-brand-color) !important;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand:hover {
|
|
||||||
color: var(--navbar-brand-hover) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.nav-link,
|
|
||||||
.nav-item > a.nav-link,
|
|
||||||
.dropdown-item {
|
|
||||||
font-family: "Product Sans";
|
|
||||||
font-size: larger;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
|
|
||||||
color: var(--navbar-link-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover,
|
|
||||||
.nav-link:focus {
|
|
||||||
background-color: var(--navbar-link-hover-bg) !important;
|
|
||||||
color: var(--navbar-link-hover-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border-top: 1px solid var(--navbar-divider-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
ANIMACIONES
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/* ================================
|
|
||||||
THEME TOGGLE - BASE
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.theme-toggle {
|
|
||||||
width: auto;
|
|
||||||
height: 40px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
display: flex;
|
|
||||||
padding: 0 1rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
background-color: var(--toggle-bg);
|
|
||||||
color: var(--toggle-fg);
|
|
||||||
font-family: 'Product Sans';
|
|
||||||
font-size: 1.2rem;
|
|
||||||
transition:
|
|
||||||
background-color 0.3s ease,
|
|
||||||
color 0.3s ease,
|
|
||||||
transform 0.2s ease,
|
|
||||||
box-shadow 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
HOVER / ACTIVE STATES
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.theme-toggle:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
LIGHT THEME
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.light {
|
|
||||||
--toggle-bg: #1e1e1e;
|
|
||||||
--toggle-fg: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.light .theme-toggle {
|
|
||||||
box-shadow: 0 0px 10px rgba(68, 7, 182, 0.808);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
DARK THEME
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--toggle-bg: #f0f0f0;
|
|
||||||
--toggle-fg: #1e1e1e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .theme-toggle {
|
|
||||||
box-shadow: 0 0px 10px rgba(206, 180, 36, 0.589);
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
/* ================================
|
|
||||||
FUENTES PERSONALIZADAS
|
|
||||||
================================== */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Open Sans";
|
|
||||||
src: url('/fonts/OpenSans.ttf');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Product Sans";
|
|
||||||
src: url('/fonts/ProductSansRegular.ttf');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Product Sans Italic";
|
|
||||||
src: url('/fonts/ProductSansItalic.ttf');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Product Sans Italic Bold";
|
|
||||||
src: url('/fonts/ProductSansBoldItalic.ttf');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Product Sans Bold";
|
|
||||||
src: url('/fonts/ProductSansBold.ttf');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
PALETA DE COLORES
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--highlight-border: var(--accent-color);
|
|
||||||
--box-shadow-soft: 0 4px 6px var(--shadow-color);
|
|
||||||
--alert-bg: #f8d7da;
|
|
||||||
}
|
|
||||||
|
|
||||||
.light {
|
|
||||||
--primary-color: #333;
|
|
||||||
--secondary-color: #555;
|
|
||||||
--tertiary-color: #777;
|
|
||||||
--border-color: #ccc;
|
|
||||||
--divider-color: #ddd;
|
|
||||||
--bg-color: #fff;
|
|
||||||
--fg-color: #111;
|
|
||||||
--text-color: #111;
|
|
||||||
--muted-color: #666;
|
|
||||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
|
||||||
--bg-hover-color: #f0f0f0;
|
|
||||||
--bg-search-bar: rgba(0,0,0,0.05);
|
|
||||||
--input-bg: #fff;
|
|
||||||
--input-border: #ccc;
|
|
||||||
--placeholder-color: #999;
|
|
||||||
--input-text: var(--text-color);
|
|
||||||
--accent-color: #333;
|
|
||||||
--btn-bg: #333;
|
|
||||||
--btn-bg-hover: #555;
|
|
||||||
--btn-text: #fff;
|
|
||||||
--btn-text-hover: #fff;
|
|
||||||
--icon-color: var(--fg-color);
|
|
||||||
--highlight-border: #777;
|
|
||||||
--card-bg: #fff;
|
|
||||||
--card-button: #fff;
|
|
||||||
--card-border: #ccc;
|
|
||||||
--card-text: var(--text-color);
|
|
||||||
--card-text-secondary: #555;
|
|
||||||
--card-btn-hover: rgba(0,0,0,0.05);
|
|
||||||
--card-muted-text: #666;
|
|
||||||
--item-bg: #fff;
|
|
||||||
--item-text: var(--text-color);
|
|
||||||
--subtitle-color: #666;
|
|
||||||
--login-bg: #f9f9f9;
|
|
||||||
--label-color: var(--text-color);
|
|
||||||
--login-btn-bg: #333;
|
|
||||||
--login-btn-hover: #555;
|
|
||||||
--login-btn-text: #fff;
|
|
||||||
--login-btn-text-hover: #111;
|
|
||||||
--header-mask-color: rgba(0,0,0,0.1);
|
|
||||||
--navbar-bg: #fff;
|
|
||||||
--navbar-brand-color: #333;
|
|
||||||
--navbar-brand-hover: #555;
|
|
||||||
--navbar-link-color: #111;
|
|
||||||
--navbar-link-hover-bg: #f0f0f0;
|
|
||||||
--navbar-link-hover-color: #333;
|
|
||||||
--navbar-dropdown-bg: #fff;
|
|
||||||
--navbar-dropdown-item-color: #111;
|
|
||||||
--navbar-dropdown-item-hover-color: #333;
|
|
||||||
--navbar-divider-color: #ccc;
|
|
||||||
--hamburger-color: #333;
|
|
||||||
--navbar-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
|
||||||
--show-btn-color: #333;
|
|
||||||
--show-btn-hover: #555;
|
|
||||||
--header-btn-hover: rgba(0,0,0,0.05);
|
|
||||||
--list-hover-bg: rgba(0,0,0,0.03);
|
|
||||||
--list-hover-bg-light: #f5f5f5;
|
|
||||||
--list-active-bg-light: #e0e0e0;
|
|
||||||
--search-bg: rgba(255,255,255,0.6);
|
|
||||||
--search-border: #ccc;
|
|
||||||
--search-input-color: #111;
|
|
||||||
--search-placeholder: #999;
|
|
||||||
--toolbar-btn-color: #111;
|
|
||||||
--toolbar-btn-hover: rgba(0,0,0,0.07);
|
|
||||||
--modal-bg: #fff;
|
|
||||||
--modal-header-border: #ccc;
|
|
||||||
--modal-body-bg: #fff;
|
|
||||||
--modal-close-color: #111;
|
|
||||||
--contact-info-bg: #f5f5f5;
|
|
||||||
--balance-report-bg: #fff;
|
|
||||||
--file-card-bg: #fff;
|
|
||||||
--sidebar-bg: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--primary-color: #eee;
|
|
||||||
--secondary-color: #ccc;
|
|
||||||
--tertiary-color: #999;
|
|
||||||
--border-color: #444;
|
|
||||||
--divider-color: #555;
|
|
||||||
--bg-color: #111;
|
|
||||||
--fg-color: #fff;
|
|
||||||
--text-color: #fff;
|
|
||||||
--muted-color: #aaa;
|
|
||||||
--shadow-color: rgba(0,0,0,0.5);
|
|
||||||
--bg-hover-color: #222;
|
|
||||||
--bg-search-bar: rgba(255,255,255,0.05);
|
|
||||||
--input-bg: #222;
|
|
||||||
--input-border: #555;
|
|
||||||
--placeholder-color: #888;
|
|
||||||
--input-text: #fff;
|
|
||||||
--accent-color: #eee;
|
|
||||||
--btn-bg: #eee;
|
|
||||||
--btn-bg-hover: #ccc;
|
|
||||||
--btn-text: #111;
|
|
||||||
--btn-text-hover: #000;
|
|
||||||
--icon-color: #fff;
|
|
||||||
--highlight-border: #999;
|
|
||||||
--alert-bg: #500;
|
|
||||||
--card-bg: #222;
|
|
||||||
--card-button: #222;
|
|
||||||
--card-border: #555;
|
|
||||||
--card-text: #fff;
|
|
||||||
--card-text-secondary: #ccc;
|
|
||||||
--card-btn-hover: rgba(255,255,255,0.05);
|
|
||||||
--item-bg: #222;
|
|
||||||
--item-text: #fff;
|
|
||||||
--subtitle-color: #aaa;
|
|
||||||
--login-bg: #111;
|
|
||||||
--label-color: #fff;
|
|
||||||
--login-btn-bg: #eee;
|
|
||||||
--login-btn-hover: #ccc;
|
|
||||||
--login-btn-text: #111;
|
|
||||||
--login-btn-text-hover: #000;
|
|
||||||
--header-mask-color: rgba(0,0,0,0.3);
|
|
||||||
--navbar-bg: #111;
|
|
||||||
--navbar-brand-color: #eee;
|
|
||||||
--navbar-brand-hover: #ccc;
|
|
||||||
--navbar-link-color: #fff;
|
|
||||||
--navbar-link-hover-bg: #222;
|
|
||||||
--navbar-link-hover-color: #ccc;
|
|
||||||
--navbar-dropdown-bg: #222;
|
|
||||||
--navbar-dropdown-item-color: #fff;
|
|
||||||
--navbar-dropdown-item-hover-color: #ccc;
|
|
||||||
--navbar-divider-color: #555;
|
|
||||||
--hamburger-color: #eee;
|
|
||||||
--navbar-shadow: 0 2px 5px rgba(0,0,0,0.5);
|
|
||||||
--show-btn-color: #eee;
|
|
||||||
--show-btn-hover: #ccc;
|
|
||||||
--card-muted-text: #aaa;
|
|
||||||
--header-btn-hover: rgba(255,255,255,0.05);
|
|
||||||
--list-hover-bg: rgba(255,255,255,0.03);
|
|
||||||
--list-hover-bg-dark: #333;
|
|
||||||
--list-active-bg-dark: #444;
|
|
||||||
--search-bg: rgba(255,255,255,0.1);
|
|
||||||
--search-border: #555;
|
|
||||||
--search-input-color: #fff;
|
|
||||||
--search-placeholder: #888;
|
|
||||||
--toolbar-btn-color: #fff;
|
|
||||||
--toolbar-btn-hover: rgba(255,255,255,0.08);
|
|
||||||
--modal-bg: #222;
|
|
||||||
--modal-header-border: #555;
|
|
||||||
--modal-body-bg: #222;
|
|
||||||
--modal-close-color: #fff;
|
|
||||||
--contact-info-bg: #111;
|
|
||||||
--balance-report-bg: #222;
|
|
||||||
--file-card-bg: #222;
|
|
||||||
--sidebar-bg: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
ESTILOS BASE / RESET SUAVE
|
|
||||||
================================== */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
font-family: "Open Sans", sans-serif;
|
|
||||||
color: var(--text-color);
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: transparent !important; /* compatibilidad navbar fija */
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
color: var(--text-color);
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tipografía global */
|
|
||||||
div,
|
|
||||||
label,
|
|
||||||
input,
|
|
||||||
p,
|
|
||||||
span,
|
|
||||||
a,
|
|
||||||
button {
|
|
||||||
font-family: "Open Sans", sans-serif;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
font-family: "Product Sans", sans-serif;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export const useData = (config) => {
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [dataLoading, setLoading] = useState(true);
|
|
||||||
const [dataError, setError] = useState(null);
|
|
||||||
const configRef = useRef();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (config?.baseUrl) {
|
|
||||||
configRef.current = config;
|
|
||||||
}
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const getAuthHeaders = () => ({
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `Bearer ${localStorage.getItem("token")}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
const current = configRef.current;
|
|
||||||
if (!current?.baseUrl) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get(current.baseUrl, {
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
params: current.params,
|
|
||||||
});
|
|
||||||
setData(response.data.data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.response?.data?.message || err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (config?.baseUrl) {
|
|
||||||
fetchData();
|
|
||||||
}
|
|
||||||
}, [config, fetchData]);
|
|
||||||
|
|
||||||
const getData = async (url, params = {}) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(url, {
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
return { data: response.data.data, error: null };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
data: null,
|
|
||||||
error: err.response?.data?.message || err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const postData = async (endpoint, payload) => {
|
|
||||||
const headers = {
|
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
||||||
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
|
||||||
};
|
|
||||||
const response = await axios.post(endpoint, payload, { headers });
|
|
||||||
await fetchData();
|
|
||||||
return response.data.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const postDataValidated = async (endpoint, payload) => {
|
|
||||||
try {
|
|
||||||
const headers = {
|
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
||||||
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
|
||||||
};
|
|
||||||
const response = await axios.post(endpoint, payload, { headers });
|
|
||||||
return { data: response.data.data, errors: null };
|
|
||||||
} catch (err) {
|
|
||||||
const raw = err.response?.data?.message;
|
|
||||||
let parsed = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return { data: null, errors: { general: raw || err.message } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: null, errors: parsed };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const putData = async (endpoint, payload) => {
|
|
||||||
const response = await axios.put(endpoint, payload, {
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
});
|
|
||||||
await fetchData();
|
|
||||||
return response.data.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteData = async (endpoint) => {
|
|
||||||
const response = await axios.delete(endpoint, {
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
});
|
|
||||||
await fetchData();
|
|
||||||
return response.data.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteDataWithBody = async (endpoint, payload) => {
|
|
||||||
const response = await axios.delete(endpoint, {
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
data: payload,
|
|
||||||
});
|
|
||||||
await fetchData();
|
|
||||||
return response.data.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
dataLoading,
|
|
||||||
dataError,
|
|
||||||
getData,
|
|
||||||
postData,
|
|
||||||
postDataValidated,
|
|
||||||
putData,
|
|
||||||
deleteData,
|
|
||||||
deleteDataWithBody,
|
|
||||||
};
|
|
||||||
};
|
|
||||||