Author SHA1 Message Date
Florian THIERRY c4f887bf33 Use signals ont publication creation and update pages. 2024-09-25 09:27:24 +02:00
Florian THIERRY 61ec781bbb Use signals in side menu. 2024-09-25 09:19:07 +02:00
Florian THIERRY 3ee41cf571 Use signals on MyPublications page. 2024-09-25 09:11:22 +02:00
Florian THIERRY 7f2e762a44 Use signals in publication page. 2024-09-25 09:07:13 +02:00
Florian THIERRY cfa3015a7b Use signals on home page. 2024-09-25 08:59:32 +02:00
Florian THIERRY 778a92fd5e Enable zoneless configuration. 2024-09-25 08:36:06 +02:00
florian ff52a198dc Upgrade to angular 18. 2024-09-24 22:47:15 +02:00
Florian THIERRY f3d59a0ef3 conf cleaning. 2024-09-22 13:18:08 +02:00
Florian THIERRY d84485e52b test 2024-09-22 13:03:39 +02:00
Florian THIERRY f789d89995 test 2024-09-22 12:53:55 +02:00
Florian THIERRY 7e0174bcc2 test 2024-09-22 12:46:41 +02:00
Florian THIERRY fe1d59a3bb test 2024-09-22 12:37:01 +02:00
Florian THIERRY 69a99c9312 test 2024-09-22 12:34:02 +02:00
Florian THIERRY a6414ae64d test 2024-09-22 12:30:18 +02:00
Florian THIERRY 9cf47f0e2a test 2024-09-22 12:27:59 +02:00
Florian THIERRY 6c89562dc3 test 2024-09-22 12:14:04 +02:00
Florian THIERRY e85eabbed5 test 2024-09-22 12:01:05 +02:00
Florian THIERRY 1ec4ba8212 test conf prod. 2024-09-22 11:10:01 +02:00
Florian THIERRY a1ff181443 test conf prod. 2024-09-22 11:09:14 +02:00
Florian THIERRY ee8f48bc43 Fix prod fr configuration. 2024-09-22 10:55:49 +02:00
Florian THIERRY 7ec1aee884 test 2024-09-21 23:39:46 +02:00
Florian THIERRY a3adfa8ee0 Fix bug of preview content. 2024-09-21 22:20:00 +02:00
Florian THIERRY d893afa1f3 i18n 2024-09-21 21:43:09 +02:00
Florian THIERRY d984128176 i18n 2024-09-21 21:40:49 +02:00
Florian THIERRY f8d73c9ed0 i18n 2024-09-21 21:34:16 +02:00
Florian THIERRY 208b935ffa i18n for some components. 2024-09-21 21:17:31 +02:00
Florian THIERRY f12dfc7029 i18n for files in core package. 2024-09-21 21:09:36 +02:00
Florian THIERRY 98a890e915 i18n for signin page. 2024-09-21 21:06:47 +02:00
Florian THIERRY 0c1b52d734 i18n for publication search page. 2024-09-21 21:03:34 +02:00
Florian THIERRY 3f6764dd7d i18n for publication update page. 2024-09-21 21:00:59 +02:00
Florian THIERRY 67c3d0b3e6 i18n of publication creation page. 2024-09-21 20:57:05 +02:00
Florian THIERRY 36208ef071 i18n of publication page. 2024-09-21 12:28:55 +02:00
Florian THIERRY b546a0cf01 Add i18n completion tool. 2024-09-21 12:20:22 +02:00
Florian THIERRY 3935f6ad21 i18n for login page. 2024-09-21 11:22:35 +02:00
Florian THIERRY fd5ad7e88e i18n for disconnection page. 2024-09-21 11:13:08 +02:00
Florian THIERRY 8c957fe694 i18n for home page. 2024-09-21 11:12:07 +02:00
Florian THIERRY cb0ef7ddd5 Test 2024-09-20 18:38:17 +02:00
Florian THIERRY 42e466fe8b test ci. 2024-09-20 17:32:43 +02:00
Florian THIERRY 2c5fa4fa13 test ci. 2024-09-20 17:25:37 +02:00
Florian THIERRY ebce44c889 test ci 2024-09-20 17:12:13 +02:00
Florian THIERRY 955dc48f51 test ci 2024-09-20 16:46:11 +02:00
Florian THIERRY 29e75e6298 Test ci. 2024-09-20 16:34:29 +02:00
Florian THIERRY 187fd105d3 Fix pictures paths. 2024-09-20 09:36:10 +02:00
Florian THIERRY 1b92fd269e Fix config. 2024-09-19 22:24:39 +02:00
Florian THIERRY 23025e3606 Fix fucking MIME types of javascript files. 2024-09-19 22:13:10 +02:00
Florian THIERRY 38c11e2d9f gitignore. 2024-09-19 18:03:33 +02:00
Florian THIERRY 610723c561 CI/CD drafting. 2024-09-19 18:03:23 +02:00
Florian THIERRY 0e2fb945a4 Initialize docker build configuration. 2024-09-18 10:18:40 +02:00
Florian THIERRY ef32572521 Change favicon. 2024-09-18 09:22:11 +02:00
Florian THIERRY e5a128a7f6 Fix style. 2024-09-18 09:10:46 +02:00
Florian THIERRY f5e1e10ebd Add confirmation dialog to delete publications. 2024-09-18 09:05:18 +02:00
Florian THIERRY 500952d4d4 Implementation of categoryId edition. 2024-09-10 10:47:58 +02:00
Florian THIERRY 1cc4abc24e Add default picture and factorization of title in publication edition. 2024-09-10 09:40:45 +02:00
Florian THIERRY 00945de270 Code cleaning about publication edition component. 2024-09-06 10:20:05 +02:00
Florian THIERRY 5804d8cc9f Extract publication edition into a separated component. 2024-09-05 17:28:30 +02:00
Florian THIERRY 5610bd170a Minor changes 2024-09-05 09:35:46 +02:00
Florian THIERRY 21d19d4ecd Styling elements. 2024-09-05 09:33:07 +02:00
Florian THIERRY f3dfac6bc7 Add delete button. 2024-09-05 09:23:25 +02:00
Florian THIERRY 4565192d0b Remote temp configuration. 2024-09-04 14:20:31 +02:00
Florian THIERRY d7ac4966c9 Add "new publication" button. 2024-09-04 14:19:26 +02:00
Florian THIERRY 64119a956a Add "my-publications" page. 2024-09-04 14:06:10 +02:00
Florian THIERRY c4dea2cc85 Dependency cleaning. 2024-09-04 10:28:22 +02:00
Florian THIERRY b0cc42fddd Implementaiton of preview tab. 2024-09-04 10:26:47 +02:00
Florian THIERRY db669114b2 Add Prism.js. 2024-09-04 09:29:10 +02:00
Florian THIERRY ca6b207816 Fix refresh token mecanism. 2024-09-04 08:58:47 +02:00
Florian THIERRY b091dc52b7 Fix authentication errors handling. 2024-09-03 11:45:47 +02:00
Florian THIERRY 4d44b6f53c First implementation of refresh token. 2024-09-03 10:37:58 +02:00
Florian THIERRY be34c555a5 Implementation of code-block addition in the publication. 2024-09-03 10:07:13 +02:00
Florian THIERRY c03d977028 Add code-block dialog and style it. 2024-09-03 10:00:45 +02:00
Florian THIERRY afd184f936 Fix author-id publications search. 2024-08-30 22:37:03 +02:00
Florian THIERRY c09c68e1ac Implementation of picture addition button in editor. 2024-08-30 18:13:43 +02:00
Florian THIERRY b84ba15f4c Implementaiton of h1, h2, h3 and link button on publication editor. 2024-08-30 18:01:29 +02:00
Florian THIERRY 51af25666d Fix search bar navigation. 2024-08-30 09:59:21 +02:00
Florian THIERRY b3a52f6a4b Extract search bar into a standalone component and fix header design. 2024-08-30 09:52:00 +02:00
Florian THIERRY 090143fdae Add search publication page and fix category access 2024-08-29 17:24:01 +02:00
Florian THIERRY b5f881e2c5 Add a component to display a publication list and fix publication search rest service to handle ids. 2024-08-29 13:56:14 +02:00
Florian THIERRY d9b856bd43 Fix categories loading in side menu component. 2024-08-29 11:09:39 +02:00
Florian THIERRY 5e4068b141 Add style and help button. 2024-08-29 10:20:31 +02:00
Florian THIERRY a2f1b511c1 Add buttons in publication editor. 2024-08-25 22:40:11 +02:00
Florian THIERRY 4d20d5f8b8 Add button to edit publications on publication view page. 2024-08-25 12:02:52 +02:00
Florian THIERRY f00fb103ba Add submit button component and style it. 2024-08-22 10:26:38 +02:00
Florian THIERRY b1d9344574 Styling picture selection components. 2024-08-19 23:02:39 +02:00
Florian THIERRY 56ac024cba Mess commit. 2024-08-19 22:42:42 +02:00
Florian THIERRY 32ab1d79c8 Add publication edition form. 2024-08-04 15:54:00 +02:00
Florian THIERRY 42c4f76c0d Design some buttons in header. 2024-06-28 09:35:56 +02:00
Florian THIERRY 4cc2a15231 Responsive styling of header. 2024-06-12 13:36:16 +02:00
Florian THIERRY 54fbc7d609 Adding css transitions. 2024-06-12 13:22:20 +02:00
Florian THIERRY 8e9440a104 Responsive styling of publication page. 2024-06-12 13:19:08 +02:00
Florian THIERRY 00d49d5fa4 Responsive styling of home page. 2024-06-12 13:15:03 +02:00
Florian THIERRY 78325c8729 Fix footer at page bottom. 2024-06-12 12:38:42 +02:00
Florian THIERRY 1e18e3bc52 Add signin page. 2024-06-11 12:55:11 +02:00
Florian THIERRY 8ada2a15ef Styling login form. 2024-06-07 09:44:51 +02:00
Florian THIERRY 95d5308934 Re-design of login page. 2024-06-06 11:05:02 +02:00
Florian THIERRY e5076f0c64 Design header search bar. 2024-06-05 10:23:38 +02:00
Florian THIERRY 2ba707c336 Fix home page design. 2024-06-05 10:05:02 +02:00
Florian THIERRY d3041cf03d Styling publications on home page. 2024-06-04 13:55:26 +02:00
Florian THIERRY 58295398e0 Add endpoint to retrieve latest publications. 2024-06-04 13:06:20 +02:00
Florian THIERRY 067bf7885a Update side menu header. 2024-06-04 12:55:46 +02:00
Florian THIERRY d324b94ddb Add SQL script to rebuild categories hierarchy. 2024-04-22 16:05:59 +02:00
Florian THIERRY 4985889c58 Styling header. 2024-04-22 16:05:35 +02:00
Florian THIERRY 7f5d52dce5 Add side menu for header. 2024-04-22 15:57:22 +02:00
Florian THIERRY fae709a254 Fix class not found error. 2024-04-22 14:37:20 +02:00
Florian THIERRY 45355f6c42 Refactor publication parser location. 2024-04-22 14:22:36 +02:00
Florian THIERRY db492b6316 Add parsed text to publication entities. 2024-04-22 14:13:17 +02:00
Florian THIERRY c54e1c57d7 Creation of side-menu. 2024-04-02 16:18:03 +02:00
Florian THIERRY 0900df463a Add disconnection and minor improvements on login page. 2024-03-27 12:15:41 +01:00
165 changed files with 11441 additions and 4082 deletions
+2
View File
@@ -82,3 +82,5 @@ testem.log
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
**/ci/bin/
+15
View File
@@ -0,0 +1,15 @@
FROM maven:3.9.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY backend/pom.xml /app/
COPY backend/codiki-application /app/codiki-application
COPY backend/codiki-domain /app/codiki-domain
COPY backend/codiki-exposition /app/codiki-exposition
COPY backend/codiki-infrastructure /app/codiki-infrastructure
COPY backend/codiki-launcher /app/codiki-launcher
WORKDIR /app
RUN mvn clean install -N
RUN mvn clean package
FROM eclipse-temurin:21-jre-alpine AS final
COPY --from=builder /app/codiki-launcher/target/*.jar /app/codiki.jar
CMD ["java", "-jar", "/app/codiki.jar"]
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY frontend /app
RUN npm install
RUN npm run build-prod-en
RUN npm run build-prod-fr
FROM nginx:1.27-alpine AS final
WORKDIR /app
COPY --from=builder /app/dist/codiki/en/browser /usr/share/nginx/html/en/
COPY --from=builder /app/dist/codiki/fr/browser/fr /usr/share/nginx/html/fr/
COPY frontend/conf/nginx.conf /etc/nginx/nginx.conf
Vendored
+84
View File
@@ -0,0 +1,84 @@
pipeline {
agent any
stages {
stage('Configuration') {
steps {
dir('codiki-properties') {
git url:'https://gitea.takiguchi.ovh/Codiki/codiki-properties.git', branch: 'hexagonal-reforged', credentialsId: 'Jenkins-gitea'
}
script {
sh 'sed -i "s/<POSTGRES_PASSWORD>/$(cat ./codiki-properties/passwords/postgresql/codiki_user)/g" ./codiki-properties/application-prod.yml'
sh 'cp ./codiki-properties/application-prod.yml ./backend/codiki-launcher/src/main/resources/application-prod.yml'
sh 'sed -i "s/<POSTGRES_PASSWORD>/$(cat ./codiki-properties/passwords/postgresql/codiki_admin)/g" ./docker-compose.yml'
}
}
}
stage('Build') {
steps {
script {
sh """
sudo /usr/bin/docker build -t codiki-backend -f ./Dockerfile-backend . --no-cache
sudo /usr/bin/docker build -t codiki-frontend -f ./Dockerfile-frontend . --no-cache
sudo /usr/bin/docker save codiki-backend:latest -o ./codiki-backend.tar
sudo /usr/bin/docker save codiki-frontend:latest -o ./codiki-frontend.tar
sudo chown jenkins:jenkins ./codiki-backend.tar
sudo chown jenkins:jenkins ./codiki-frontend.tar
chmod 644 ./codiki-backend.tar
chmod 644 ./codiki-frontend.tar
"""
}
}
}
stage('Deploy') {
steps {
sshPublisher(
publishers: [
sshPublisherDesc(
configName: 'DebianServer [codiki]',
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: '',
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: '',
remoteDirectorySDF: false,
removePrefix: '',
sourceFiles: 'codiki-backend.tar,codiki-frontend.tar,docker-compose.yml'
),
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: """
cd /opt/codiki
sudo /usr/bin/docker load < /opt/codiki/codiki-backend.tar
sudo /usr/bin/docker load < /opt/codiki/codiki-frontend.tar
sudo /usr/bin/docker compose down
sudo /usr/bin/docker compose up --detach
""",
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: '',
remoteDirectorySDF: false,
removePrefix: '',
sourceFiles: ''
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: true
)
]
)
}
}
}
}
+4
View File
@@ -33,6 +33,10 @@
<groupId>com.auth0</groupId> <groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId> <artifactId>java-jwt</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId> <artifactId>junit-jupiter-api</artifactId>
@@ -1,6 +1,8 @@
package org.codiki.application.picture; package org.codiki.application.picture;
import java.io.File; import java.io.File;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -30,6 +32,7 @@ public class PictureUseCases {
.withId(UUID.randomUUID()) .withId(UUID.randomUUID())
.withPublisher(authenticatedUser) .withPublisher(authenticatedUser)
.withContentFile(pictureFile) .withContentFile(pictureFile)
.withPublicationDate(ZonedDateTime.now())
.build(); .build();
picturePort.save(newPicture); picturePort.save(newPicture);
@@ -48,4 +51,11 @@ public class PictureUseCases {
public boolean existsById(UUID pictureId) { public boolean existsById(UUID pictureId) {
return picturePort.existsById(pictureId); return picturePort.existsById(pictureId);
} }
public List<Picture> getAllOfCurrentUser() {
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
return picturePort.findAllByPublisherId(authenticatedUser.id());
}
} }
@@ -0,0 +1,144 @@
package org.codiki.application.publication;
import org.springframework.stereotype.Service;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.apache.commons.lang3.StringEscapeUtils.escapeHtml4;
@Service
public class ParserService {
private static final String REG_CODE = "\\[code lg=&quot;([a-z]+)&quot;\\](.*)\\[\\/code\\]\\n";
private static final String REG_IMAGES = "\\[img src=&quot;([^\"| ]+)&quot;( alt=&quot;([^\"| ]+)&quot;)? \\/\\]";
private static final String REG_LINKS = "\\[link href=&quot;([^\"| ]+)&quot; txt=&quot;([^\"| ]+)&quot; \\/\\]";
private static final Pattern PATTERN_CODE;
private static final Pattern PATTERN_IMAGES;
private static final Pattern PATTERN_LINKS;
static {
PATTERN_CODE = Pattern.compile(REG_CODE);
PATTERN_IMAGES = Pattern.compile(REG_IMAGES);
PATTERN_LINKS = Pattern.compile(REG_LINKS);
}
public String parse(String pSource) {
return unParseDolars(parseCode(parseHeaders(parseImages(parseLinks(parseBackSpaces(escapeHtml4(parseDolars(pSource))))))));
}
private String parseDolars(final String pSource) {
return pSource.replace("$", "£ø");
}
private String unParseDolars(final String pSource) {
return pSource.replace("&pound;&oslash;", "$");
}
String parseHeaders(final String pSource) {
String result = pSource;
for(int i = 1 ; i <= 3 ; i++) {
result = parseHeadersHX(result, i);
}
return result;
}
String parseHeadersHX(final String pSource, final int pNumHeader) {
String result = pSource;
// (.*)(\[hX\](.*)\[\/hX\])+(.*)
final String regex = concat("(.*)(\\[h", pNumHeader, "\\](.*)\\[\\/h", pNumHeader, "\\])+(.*)");
final Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(result);
while(matcher.find()) {
// \1<hX>\3</hX>\4
result = matcher.replaceFirst(concat(matcher.group(1),
"<h", pNumHeader, ">", matcher.group(3), "</h", pNumHeader, ">", matcher.group(4)));
matcher = pattern.matcher(result);
}
return result;
}
String parseBackSpaces(final String pSource) {
return pSource.replaceAll("\r?\n", "<br/>").replaceAll("\\[\\/code\\]<br\\/><br\\/>", "[/code]\n");
}
String parseImages(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_IMAGES.matcher(result);
while(matcher.find()) {
String altStr = matcher.group(3);
if(altStr == null) {
altStr = "";
}
result = matcher.replaceFirst(concat("<img src=\"", matcher.group(1), "\" alt=\"", altStr, "\" />"));
matcher = PATTERN_IMAGES.matcher(result);
}
return result;
}
String parseLinks(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_LINKS.matcher(result);
while(matcher.find()) {
result = matcher.replaceFirst(concat("<a href=\"", matcher.group(1), "\">", matcher.group(2), "</a>"));
matcher = PATTERN_LINKS.matcher(result);
}
return result;
}
protected String parseCode(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_CODE.matcher(result);
while(matcher.find()) {
// replace the '<br/>' in group by '\n'
String codeContent = matcher.group(2).replaceAll("<br\\/>", "\n");
if(codeContent.startsWith("\n")) {
codeContent = codeContent.substring(1);
}
result = matcher.replaceFirst(
concat(
"<pre class=\"line-numbers\"><code class=\"language-",
matcher.group(1),
"\">",
codeContent,
"</code></pre>"
)
);
matcher = PATTERN_CODE.matcher(result);
}
return result;
}
/**
* Concatenate the parameters to form just one single string.
*
* @param pArgs
* The strings to concatenate.
* @return The parameters concatenated.
*/
private static String concat(final Object... pArgs) {
final StringBuilder result = new StringBuilder();
for (final Object arg : pArgs) {
result.append(arg);
}
return result.toString();
}
}
@@ -3,12 +3,15 @@ package org.codiki.application.publication;
import static java.util.Objects.isNull; import static java.util.Objects.isNull;
import java.time.Clock; import java.time.Clock;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.codiki.domain.publication.model.builder.AuthorBuilder.anAuthor; import static org.codiki.domain.publication.model.builder.AuthorBuilder.anAuthor;
import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication; import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.category.CategoryUseCases; import org.codiki.application.category.CategoryUseCases;
import org.codiki.application.picture.PictureUseCases; import org.codiki.application.picture.PictureUseCases;
import org.codiki.application.user.UserUseCases; import org.codiki.application.user.UserUseCases;
@@ -31,6 +34,7 @@ public class PublicationUseCases {
private final CategoryUseCases categoryUseCases; private final CategoryUseCases categoryUseCases;
private final Clock clock; private final Clock clock;
private final KeyGenerator keyGenerator; private final KeyGenerator keyGenerator;
private final ParserService parserService;
private final PictureUseCases pictureUseCases; private final PictureUseCases pictureUseCases;
private final PublicationCreationRequestValidator publicationCreationRequestValidator; private final PublicationCreationRequestValidator publicationCreationRequestValidator;
private final PublicationPort publicationPort; private final PublicationPort publicationPort;
@@ -42,6 +46,7 @@ public class PublicationUseCases {
CategoryUseCases categoryUseCases, CategoryUseCases categoryUseCases,
Clock clock, Clock clock,
KeyGenerator keyGenerator, KeyGenerator keyGenerator,
ParserService parserService,
PictureUseCases pictureUseCases, PictureUseCases pictureUseCases,
PublicationCreationRequestValidator publicationCreationRequestValidator, PublicationCreationRequestValidator publicationCreationRequestValidator,
PublicationPort publicationPort, PublicationPort publicationPort,
@@ -52,6 +57,7 @@ public class PublicationUseCases {
this.categoryUseCases = categoryUseCases; this.categoryUseCases = categoryUseCases;
this.clock = clock; this.clock = clock;
this.keyGenerator = keyGenerator; this.keyGenerator = keyGenerator;
this.parserService = parserService;
this.publicationCreationRequestValidator = publicationCreationRequestValidator; this.publicationCreationRequestValidator = publicationCreationRequestValidator;
this.publicationPort = publicationPort; this.publicationPort = publicationPort;
this.publicationUpdateRequestValidator = publicationUpdateRequestValidator; this.publicationUpdateRequestValidator = publicationUpdateRequestValidator;
@@ -83,6 +89,7 @@ public class PublicationUseCases {
.withKey(keyGenerator.generateKey()) .withKey(keyGenerator.generateKey())
.withTitle(request.title()) .withTitle(request.title())
.withText(request.text()) .withText(request.text())
.withParsedText(parserService.parse(request.text()))
.withDescription(request.description()) .withDescription(request.description())
.withCreationDate(ZonedDateTime.now(clock)) .withCreationDate(ZonedDateTime.now(clock))
.withIllustrationId(request.illustrationId()) .withIllustrationId(request.illustrationId())
@@ -116,6 +123,7 @@ public class PublicationUseCases {
if (!isNull(request.text())) { if (!isNull(request.text())) {
publicationBuilder.withText(request.text()); publicationBuilder.withText(request.text());
publicationBuilder.withParsedText(parserService.parse(request.text()));
} }
if (!isNull(request.description())) { if (!isNull(request.description())) {
@@ -163,7 +171,19 @@ public class PublicationUseCases {
} }
public Optional<Publication> findById(UUID publicationId) { public Optional<Publication> findById(UUID publicationId) {
return publicationPort.findById(publicationId); return publicationPort.findById(publicationId)
.map(publication -> {
Publication result = publication;
if (isEmpty(publication.parsedText())) {
Publication editedPublication = aPublication()
.basedOn(publication)
.withParsedText(parserService.parse(publication.text()))
.build();
publicationPort.save(editedPublication);
result = editedPublication;
}
return result;
});
} }
public List<Publication> searchPublications(String searchQuery) { public List<Publication> searchPublications(String searchQuery) {
@@ -171,4 +191,12 @@ public class PublicationUseCases {
return publicationPort.search(criteria); return publicationPort.search(criteria);
} }
public List<Publication> getLatest() {
return publicationPort.getLatest();
}
public String previewContent(String publicationText) {
return parserService.parse(publicationText);
}
} }
@@ -5,6 +5,7 @@ import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class KeyGeneratorTest { class KeyGeneratorTest {
@@ -16,6 +17,7 @@ class KeyGeneratorTest {
} }
@Test @Test
@Disabled
public void generateKey_should_generate_random_keys_with_alphanumeric_characters() { public void generateKey_should_generate_random_keys_with_alphanumeric_characters() {
Pattern validationRegex = Pattern.compile("^[0-9A-Z]{10}$"); Pattern validationRegex = Pattern.compile("^[0-9A-Z]{10}$");
@@ -1,10 +1,12 @@
package org.codiki.domain.picture.model; package org.codiki.domain.picture.model;
import java.io.File; import java.io.File;
import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
public record Picture( public record Picture(
UUID id, UUID id,
UUID publisherId, UUID publisherId,
ZonedDateTime publishedAt,
File contentFile File contentFile
) {} ) {}
@@ -1,6 +1,7 @@
package org.codiki.domain.picture.model.builder; package org.codiki.domain.picture.model.builder;
import java.io.File; import java.io.File;
import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.picture.model.Picture; import org.codiki.domain.picture.model.Picture;
@@ -9,6 +10,7 @@ import org.codiki.domain.user.model.User;
public class PictureBuilder { public class PictureBuilder {
private UUID id; private UUID id;
private UUID publisherId; private UUID publisherId;
private ZonedDateTime publishedAt;
private File contentFile; private File contentFile;
private PictureBuilder() {} private PictureBuilder() {}
@@ -37,12 +39,17 @@ public class PictureBuilder {
return withPublisherId(publisher.id()); return withPublisherId(publisher.id());
} }
public PictureBuilder withPublicationDate(ZonedDateTime publishedAt) {
this.publishedAt = publishedAt;
return this;
}
public PictureBuilder withContentFile(File contentFile) { public PictureBuilder withContentFile(File contentFile) {
this.contentFile = contentFile; this.contentFile = contentFile;
return this; return this;
} }
public Picture build() { public Picture build() {
return new Picture(id, publisherId, contentFile); return new Picture(id, publisherId, publishedAt, contentFile);
} }
} }
@@ -1,5 +1,6 @@
package org.codiki.domain.picture.port; package org.codiki.domain.picture.port;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -13,4 +14,6 @@ public interface PicturePort {
void save(Picture picture); void save(Picture picture);
void deleteById(UUID pictureId); void deleteById(UUID pictureId);
List<Picture> findAllByPublisherId(UUID id);
} }
@@ -0,0 +1,9 @@
package org.codiki.domain.publication.exception;
import org.codiki.domain.exception.FunctionnalException;
public class BadPublicationSearchCriterionException extends FunctionnalException {
public BadPublicationSearchCriterionException(String message) {
super(message);
}
}
@@ -5,7 +5,7 @@ import java.util.UUID;
public record Author( public record Author(
UUID id, UUID id,
String name, String name,
String image String photoId
) { ) {
} }
@@ -8,6 +8,7 @@ public record Publication(
String key, String key,
String title, String title,
String text, String text,
String parsedText,
String description, String description,
ZonedDateTime creationDate, ZonedDateTime creationDate,
UUID illustrationId, UUID illustrationId,
@@ -1,17 +1,17 @@
package org.codiki.domain.publication.model.builder; package org.codiki.domain.publication.model.builder;
import org.codiki.domain.publication.model.Author;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.category.model.Category;
import org.codiki.domain.publication.model.Author;
import org.codiki.domain.publication.model.Publication;
public class PublicationBuilder { public class PublicationBuilder {
private UUID id; private UUID id;
private String key; private String key;
private String title; private String title;
private String text; private String text;
private String parsedText;
private String description; private String description;
private ZonedDateTime creationDate; private ZonedDateTime creationDate;
private UUID illustrationId; private UUID illustrationId;
@@ -30,6 +30,7 @@ public class PublicationBuilder {
.withKey(publication.key()) .withKey(publication.key())
.withTitle(publication.title()) .withTitle(publication.title())
.withText(publication.text()) .withText(publication.text())
.withParsedText(publication.parsedText())
.withDescription(publication.description()) .withDescription(publication.description())
.withCreationDate(publication.creationDate()) .withCreationDate(publication.creationDate())
.withIllustrationId(publication.illustrationId()) .withIllustrationId(publication.illustrationId())
@@ -57,6 +58,11 @@ public class PublicationBuilder {
return this; return this;
} }
public PublicationBuilder withParsedText(String parsedText) {
this.parsedText = parsedText;
return this;
}
public PublicationBuilder withDescription(String description) { public PublicationBuilder withDescription(String description) {
this.description = description; this.description = description;
return this; return this;
@@ -88,6 +94,7 @@ public class PublicationBuilder {
key, key,
title, title,
text, text,
parsedText,
description, description,
creationDate, creationDate,
illustrationId, illustrationId,
@@ -15,4 +15,6 @@ public interface PublicationPort {
void delete(Publication publication); void delete(Publication publication);
List<Publication> search(List<PublicationSearchCriterion> criteria); List<Publication> search(List<PublicationSearchCriterion> criteria);
List<Publication> getLatest();
} }
@@ -7,16 +7,10 @@ import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import org.codiki.domain.category.exception.CategoryDeletionException; import org.codiki.domain.category.exception.CategoryDeletionException;
import org.codiki.domain.category.exception.CategoryEditionException; import org.codiki.domain.category.exception.CategoryEditionException;
import org.codiki.domain.category.exception.CategoryNotFoundException; import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.exception.LoginFailureException; import org.codiki.domain.exception.*;
import org.codiki.domain.exception.RefreshTokenDoesNotExistException;
import org.codiki.domain.exception.RefreshTokenExpiredException;
import org.codiki.domain.exception.UserDoesNotExistException;
import org.codiki.domain.picture.exception.PictureNotFoundException; import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.exception.PictureUploadException; import org.codiki.domain.picture.exception.PictureUploadException;
import org.codiki.domain.publication.exception.NoPublicationSearchResultException; import org.codiki.domain.publication.exception.*;
import org.codiki.domain.publication.exception.PublicationEditionException;
import org.codiki.domain.publication.exception.PublicationNotFoundException;
import org.codiki.domain.publication.exception.PublicationUpdateForbiddenException;
import org.codiki.domain.user.exception.UserAlreadyExistsException; import org.codiki.domain.user.exception.UserAlreadyExistsException;
import org.codiki.domain.user.exception.UserCreationException; import org.codiki.domain.user.exception.UserCreationException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -28,6 +22,7 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExcep
@RestControllerAdvice @RestControllerAdvice
public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHandler { public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler({ @ExceptionHandler({
BadPublicationSearchCriterionException.class,
CategoryDeletionException.class, CategoryDeletionException.class,
CategoryEditionException.class, CategoryEditionException.class,
CategoryNotFoundException.class, CategoryNotFoundException.class,
@@ -53,7 +48,8 @@ public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHan
} }
@ExceptionHandler({ @ExceptionHandler({
RefreshTokenExpiredException.class RefreshTokenExpiredException.class,
AuthenticationRequiredException.class,
}) })
public ProblemDetail handleUnauthorizedExceptions(Exception exception) { public ProblemDetail handleUnauthorizedExceptions(Exception exception) {
return buildProblemDetail(UNAUTHORIZED, exception); return buildProblemDetail(UNAUTHORIZED, exception);
@@ -48,12 +48,14 @@ public class SecurityConfiguration {
"/api/pictures/{pictureId}", "/api/pictures/{pictureId}",
"/api/publications/{publicationId}", "/api/publications/{publicationId}",
"/api/publications", "/api/publications",
"/api/publications/latest",
"/error" "/error"
).permitAll() ).permitAll()
.requestMatchers( .requestMatchers(
POST, POST,
"/api/users/login", "/api/users/login",
"/api/users/refresh-token" "/api/users/refresh-token",
"/api/users"
).permitAll() ).permitAll()
.requestMatchers( .requestMatchers(
POST, POST,
@@ -1,6 +1,7 @@
package org.codiki.exposition.picture; package org.codiki.exposition.picture;
import java.io.File; import java.io.File;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE; import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE;
@@ -8,6 +9,7 @@ import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE;
import org.codiki.application.picture.PictureUseCases; import org.codiki.application.picture.PictureUseCases;
import org.codiki.domain.picture.exception.PictureNotFoundException; import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.model.Picture; import org.codiki.domain.picture.model.Picture;
import org.codiki.exposition.picture.model.PictureDto;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -44,4 +46,12 @@ public class PictureController {
.orElseThrow(() -> new PictureNotFoundException(pictureId)); .orElseThrow(() -> new PictureNotFoundException(pictureId));
return new FileSystemResource(picture.contentFile()); return new FileSystemResource(picture.contentFile());
} }
@GetMapping("/current-user")
public List<PictureDto> getAllPicturesOfCurrentUser() {
return pictureUseCases.getAllOfCurrentUser()
.stream()
.map(PictureDto::new)
.toList();
}
} }
@@ -0,0 +1,15 @@
package org.codiki.exposition.picture.model;
import org.codiki.domain.picture.model.Picture;
import java.time.ZonedDateTime;
import java.util.UUID;
public record PictureDto(
UUID id,
ZonedDateTime publishedAt
) {
public PictureDto(Picture picture) {
this(picture.id(), picture.publishedAt());
}
}
@@ -1,28 +1,22 @@
package org.codiki.exposition.publication; package org.codiki.exposition.publication;
import org.codiki.application.publication.PublicationUseCases;
import org.codiki.domain.publication.exception.NoPublicationSearchResultException;
import org.codiki.domain.publication.exception.PublicationNotFoundException;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.codiki.exposition.publication.model.PreviewContentRequest;
import org.codiki.exposition.publication.model.PreviewContentResponse;
import org.codiki.exposition.publication.model.PublicationDto;
import org.codiki.exposition.publication.model.PublicationEditionRequestDto;
import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.http.HttpStatus.NO_CONTENT;
import static org.springframework.util.ObjectUtils.isEmpty; import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.publication.PublicationUseCases;
import org.codiki.domain.publication.exception.NoPublicationSearchResultException;
import org.codiki.domain.publication.exception.PublicationNotFoundException;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.codiki.exposition.publication.model.PublicationDto;
import org.codiki.exposition.publication.model.PublicationEditionRequestDto;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/api/publications") @RequestMapping("/api/publications")
@@ -66,7 +60,7 @@ public class PublicationController {
@GetMapping @GetMapping
public List<PublicationDto> searchPublications(@RequestParam("query") String searchQuery) { public List<PublicationDto> searchPublications(@RequestParam("query") String searchQuery) {
final List<PublicationDto> publications = publicationUseCases.searchPublications(searchQuery) List<PublicationDto> publications = publicationUseCases.searchPublications(searchQuery)
.stream() .stream()
.map(PublicationDto::new) .map(PublicationDto::new)
.toList(); .toList();
@@ -77,4 +71,24 @@ public class PublicationController {
return publications; return publications;
} }
@GetMapping("/latest")
public List<PublicationDto> getLatestPublications() {
List<PublicationDto> publications = publicationUseCases.getLatest()
.stream()
.map(PublicationDto::new)
.toList();
if (isEmpty(publications)) {
throw new NoPublicationSearchResultException();
}
return publications;
}
@PostMapping(value = "/preview")
public PreviewContentResponse previewPublicationContent(@RequestBody PreviewContentRequest request) {
String previewContent = publicationUseCases.previewContent(request.text());
return new PreviewContentResponse(previewContent);
}
} }
@@ -13,7 +13,7 @@ public record AuthorDto(
this( this(
author.id(), author.id(),
author.name(), author.name(),
author.image() author.photoId()
); );
} }
} }
@@ -0,0 +1,5 @@
package org.codiki.exposition.publication.model;
public record PreviewContentRequest(
String text
) {}
@@ -0,0 +1,5 @@
package org.codiki.exposition.publication.model;
public record PreviewContentResponse(
String text
) {}
@@ -1,16 +1,16 @@
package org.codiki.exposition.publication.model; package org.codiki.exposition.publication.model;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.publication.model.Publication;
import org.codiki.exposition.category.model.CategoryDto;
public record PublicationDto( public record PublicationDto(
UUID id, UUID id,
String key, String key,
String title, String title,
String text, String text,
String parsedText,
String description, String description,
ZonedDateTime creationDate, ZonedDateTime creationDate,
UUID illustrationId, UUID illustrationId,
@@ -23,6 +23,7 @@ public record PublicationDto(
publication.key(), publication.key(),
publication.title(), publication.title(),
publication.text(), publication.text(),
publication.parsedText(),
publication.description(), publication.description(),
publication.creationDate(), publication.creationDate(),
publication.illustrationId(), publication.illustrationId(),
@@ -1,5 +1,6 @@
package org.codiki.infrastructure.category.repository; package org.codiki.infrastructure.category.repository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.codiki.infrastructure.category.model.CategoryEntity; import org.codiki.infrastructure.category.model.CategoryEntity;
@@ -16,4 +17,7 @@ public interface CategoryRepository extends JpaRepository<CategoryEntity, UUID>
) > 0 ) > 0
""", nativeQuery = true) """, nativeQuery = true)
boolean existsAnyAssociatedPublication(@Param("categoryId") UUID categoryId); boolean existsAnyAssociatedPublication(@Param("categoryId") UUID categoryId);
@Query("SELECT c FROM CategoryEntity c LEFT JOIN FETCH c.subCategories")
List<CategoryEntity> findAll();
} }
@@ -1,6 +1,7 @@
package org.codiki.infrastructure.picture; package org.codiki.infrastructure.picture;
import java.io.File; import java.io.File;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -66,4 +67,12 @@ public class PictureJpaAdapter implements PicturePort {
public void deleteById(UUID pictureId) { public void deleteById(UUID pictureId) {
repository.deleteById(pictureId); repository.deleteById(pictureId);
} }
@Override
public List<Picture> findAllByPublisherId(UUID id) {
return repository.findAllByPublisherId(id)
.stream()
.map(PictureEntity::toDomain)
.toList();
}
} }
@@ -1,5 +1,6 @@
package org.codiki.infrastructure.picture.model; package org.codiki.infrastructure.picture.model;
import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.picture.model.Picture; import org.codiki.domain.picture.model.Picture;
@@ -24,13 +25,16 @@ public class PictureEntity {
private UUID id; private UUID id;
@Column(nullable = false) @Column(nullable = false)
private UUID publisherId; private UUID publisherId;
@Column(nullable = false)
private ZonedDateTime publishedAt;
public PictureEntity(Picture picture) { public PictureEntity(Picture picture) {
id = picture.id(); id = picture.id();
publisherId = picture.publisherId(); publisherId = picture.publisherId();
publishedAt = picture.publishedAt();
} }
public Picture toDomain() { public Picture toDomain() {
return new Picture(id, publisherId, null); return new Picture(id, publisherId, publishedAt, null);
} }
} }
@@ -1,9 +1,12 @@
package org.codiki.infrastructure.picture.repository; package org.codiki.infrastructure.picture.repository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
import org.codiki.infrastructure.picture.model.PictureEntity; import org.codiki.infrastructure.picture.model.PictureEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface PictureRepository extends JpaRepository<PictureEntity, UUID> { public interface PictureRepository extends JpaRepository<PictureEntity, UUID> {
List<PictureEntity> findAllByPublisherId(UUID id);
} }
@@ -1,19 +1,22 @@
package org.codiki.infrastructure.publication; package org.codiki.infrastructure.publication;
import static java.util.Collections.reverseOrder;
import static java.util.Comparator.comparingInt;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.publication.model.Publication; import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion; import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.port.PublicationPort; import org.codiki.domain.publication.port.PublicationPort;
import org.codiki.infrastructure.publication.model.PublicationEntity; import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchResult; import org.codiki.infrastructure.publication.model.PublicationSearchResult;
import org.codiki.infrastructure.publication.repository.PublicationRepository; import org.codiki.infrastructure.publication.repository.PublicationRepository;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static java.util.Collections.reverseOrder;
import static java.util.Comparator.comparingInt;
@Component @Component
public class PublicationJpaAdapter implements PublicationPort { public class PublicationJpaAdapter implements PublicationPort {
private final PublicationRepository repository; private final PublicationRepository repository;
@@ -46,7 +49,7 @@ public class PublicationJpaAdapter implements PublicationPort {
@Override @Override
public List<Publication> search(List<PublicationSearchCriterion> criteria) { public List<Publication> search(List<PublicationSearchCriterion> criteria) {
List<PublicationSearchCriterion> adaptedCriteria = publicationSearchCriteriaJpaAdapter.adaptCriteriaForJpa(criteria); List<PublicationSearchJpaCriterion> adaptedCriteria = publicationSearchCriteriaJpaAdapter.adaptCriteriaForJpa(criteria);
return repository.search(adaptedCriteria) return repository.search(adaptedCriteria)
.stream() .stream()
.map(PublicationEntity::toDomain) .map(PublicationEntity::toDomain)
@@ -55,4 +58,12 @@ public class PublicationJpaAdapter implements PublicationPort {
.map(PublicationSearchResult::getPublication) .map(PublicationSearchResult::getPublication)
.toList(); .toList();
} }
@Override
public List<Publication> getLatest() {
return repository.getLatest(Limit.of(10))
.stream()
.map(PublicationEntity::toDomain)
.toList();
}
} }
@@ -3,12 +3,15 @@ package org.codiki.infrastructure.publication;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import org.codiki.domain.publication.exception.BadPublicationSearchCriterionException;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion; import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaField;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
public class PublicationSearchCriteriaJpaAdapter { public class PublicationSearchCriteriaJpaAdapter {
public List<PublicationSearchCriterion> adaptCriteriaForJpa(List<PublicationSearchCriterion> initialCriteria) { public List<PublicationSearchJpaCriterion> adaptCriteriaForJpa(List<PublicationSearchCriterion> initialCriteria) {
List<PublicationSearchCriterion> result = new LinkedList<>(); List<PublicationSearchCriterion> result = new LinkedList<>();
for (PublicationSearchCriterion criterion : initialCriteria) { for (PublicationSearchCriterion criterion : initialCriteria) {
@@ -29,6 +32,19 @@ public class PublicationSearchCriteriaJpaAdapter {
} }
} }
return result; return result.stream()
.map(this::mapToJpaCriterion)
.toList();
}
private PublicationSearchJpaCriterion mapToJpaCriterion(PublicationSearchCriterion criterion) {
return new PublicationSearchJpaCriterion(
PublicationSearchJpaField.fromDomain(criterion.searchField())
.orElseThrow(() -> new BadPublicationSearchCriterionException(
String.format("Unknown field research criterion: %s", criterion.searchField()))
),
criterion.searchType(),
criterion.value()
);
} }
} }
@@ -24,17 +24,17 @@ public class AuthorEntity {
private UUID id; private UUID id;
@Column(nullable = false) @Column(nullable = false)
private String pseudo; private String pseudo;
// private String illustrationId; private String photoId;
public AuthorEntity(Author author) { public AuthorEntity(Author author) {
this( this(
author.id(), author.id(),
author.name() author.name(),
// author.illustrationId() author.photoId()
); );
} }
public Author toDomain() { public Author toDomain() {
return new Author(id, pseudo, "image"); return new Author(id, pseudo, photoId);
} }
} }
@@ -1,22 +1,16 @@
package org.codiki.infrastructure.publication.model; package org.codiki.infrastructure.publication.model;
import java.time.ZonedDateTime; import jakarta.persistence.*;
import java.util.UUID;
import org.codiki.domain.publication.model.Publication;
import org.codiki.infrastructure.category.model.CategoryEntity;
import static jakarta.persistence.FetchType.LAZY;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime;
import java.util.UUID;
import static jakarta.persistence.FetchType.LAZY;
@Entity @Entity
@Table(name = "publication") @Table(name = "publication")
@@ -34,6 +28,8 @@ public class PublicationEntity {
@Column(nullable = false) @Column(nullable = false)
private String text; private String text;
@Column(nullable = false) @Column(nullable = false)
private String parsedText;
@Column(nullable = false)
private String description; private String description;
@Column(nullable = false) @Column(nullable = false)
private ZonedDateTime creationDate; private ZonedDateTime creationDate;
@@ -51,6 +47,7 @@ public class PublicationEntity {
publication.key(), publication.key(),
publication.title(), publication.title(),
publication.text(), publication.text(),
publication.parsedText(),
publication.description(), publication.description(),
publication.creationDate(), publication.creationDate(),
publication.illustrationId(), publication.illustrationId(),
@@ -65,6 +62,7 @@ public class PublicationEntity {
key, key,
title, title,
text, text,
parsedText,
description, description,
creationDate, creationDate,
illustrationId, illustrationId,
@@ -0,0 +1,9 @@
package org.codiki.infrastructure.publication.model;
import org.codiki.domain.publication.model.search.ComparisonType;
public record PublicationSearchJpaCriterion(
PublicationSearchJpaField searchField,
ComparisonType searchType,
Object value
) { }
@@ -0,0 +1,36 @@
package org.codiki.infrastructure.publication.model;
import lombok.Getter;
import org.codiki.domain.publication.model.search.PublicationSearchField;
import java.util.Arrays;
import java.util.Optional;
@Getter
public enum PublicationSearchJpaField {
ID,
KEY,
TITLE,
TEXT,
DESCRIPTION,
CREATION_DATE("creationDate"),
CATEGORY_ID("categoryId"),
AUTHOR_ID("author.id"),
AUTHOR_PSEUDO("author.pseudo");
private final String fieldName;
PublicationSearchJpaField() {
this.fieldName = name().toLowerCase();
}
PublicationSearchJpaField(String fieldName) {
this.fieldName = fieldName;
}
public static Optional<PublicationSearchJpaField> fromDomain(PublicationSearchField publicationSearchField) {
return Arrays.stream(values())
.filter(field -> field.name().equals(publicationSearchField.name()))
.findFirst();
}
}
@@ -1,10 +1,10 @@
package org.codiki.infrastructure.publication.repository; package org.codiki.infrastructure.publication.repository;
import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaCriterion;
import java.util.List; import java.util.List;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.infrastructure.publication.model.PublicationEntity;
public interface CustomPublicationRepository { public interface CustomPublicationRepository {
List<PublicationEntity> search(List<PublicationSearchCriterion> criteria); List<PublicationEntity> search(List<PublicationSearchJpaCriterion> criteria);
} }
@@ -1,16 +1,15 @@
package org.codiki.infrastructure.publication.repository; package org.codiki.infrastructure.publication.repository;
import java.util.List;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.springframework.stereotype.Repository;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Root;
import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaCriterion;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository @Repository
public class CustomPublicationRepositoryImpl implements CustomPublicationRepository { public class CustomPublicationRepositoryImpl implements CustomPublicationRepository {
@@ -23,7 +22,7 @@ public class CustomPublicationRepositoryImpl implements CustomPublicationReposit
} }
@Override @Override
public List<PublicationEntity> search(final List<PublicationSearchCriterion> criteria) { public List<PublicationEntity> search(final List<PublicationSearchJpaCriterion> criteria) {
CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder(); CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
CriteriaQuery<PublicationEntity> query = criteriaBuilder.createQuery(PublicationEntity.class); CriteriaQuery<PublicationEntity> query = criteriaBuilder.createQuery(PublicationEntity.class);
@@ -1,14 +1,17 @@
package org.codiki.infrastructure.publication.repository; package org.codiki.infrastructure.publication.repository;
import java.util.List; import java.util.List;
import java.util.UUID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_PSEUDO;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.model.search.PublicationSearchField;
import org.codiki.infrastructure.publication.model.PublicationEntity; import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaField;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import static jakarta.persistence.criteria.JoinType.LEFT; import static jakarta.persistence.criteria.JoinType.LEFT;
import static org.codiki.infrastructure.publication.model.PublicationSearchJpaField.AUTHOR_ID;
import static org.codiki.infrastructure.publication.model.PublicationSearchJpaField.AUTHOR_PSEUDO;
import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.From;
import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Predicate;
@@ -17,7 +20,7 @@ import jakarta.persistence.criteria.Root;
@Component @Component
public class PublicationPredicateMapper { public class PublicationPredicateMapper {
public Predicate map( public Predicate map(
List<PublicationSearchCriterion> criteria, List<PublicationSearchJpaCriterion> criteria,
CriteriaBuilder criteriaBuilder, CriteriaBuilder criteriaBuilder,
Root<PublicationEntity> fromPublication Root<PublicationEntity> fromPublication
) { ) {
@@ -28,13 +31,15 @@ public class PublicationPredicateMapper {
} }
private Predicate map( private Predicate map(
PublicationSearchCriterion criterion, PublicationSearchJpaCriterion criterion,
CriteriaBuilder criteriaBuilder, CriteriaBuilder criteriaBuilder,
Root<PublicationEntity> fromPublication Root<PublicationEntity> fromPublication
) { ) {
return switch (criterion.searchType()) { return switch (criterion.searchType()) {
case EQUALS -> mapEqualsPredicate(criteriaBuilder, fromPublication, criterion.searchField(), criterion.value()); case EQUALS ->
case CONTAINS -> mapContainsPredicate(criteriaBuilder, fromPublication, criterion.searchField(), criterion.value()); mapEqualsPredicate(criteriaBuilder, fromPublication, criterion.searchField(), criterion.value());
case CONTAINS ->
mapContainsPredicate(criteriaBuilder, fromPublication, criterion.searchField(), criterion.value());
default -> null; default -> null;
}; };
} }
@@ -42,28 +47,39 @@ public class PublicationPredicateMapper {
private Predicate mapEqualsPredicate( private Predicate mapEqualsPredicate(
CriteriaBuilder criteriaBuilder, CriteriaBuilder criteriaBuilder,
Root<PublicationEntity> fromPublication, Root<PublicationEntity> fromPublication,
PublicationSearchField searchField, PublicationSearchJpaField searchField,
Object value Object value
) { ) {
Predicate result;
From<?, ?> from = fromPublication; From<?, ?> from = fromPublication;
String attributeName = searchField.name().toLowerCase(); String attributeName = searchField.getFieldName();
if (searchField == AUTHOR_PSEUDO) { if (List.of(AUTHOR_PSEUDO, AUTHOR_ID).contains(searchField)) {
from = fromPublication.join("author", LEFT); from = fromPublication.join("author", LEFT);
attributeName = "pseudo"; attributeName = switch(searchField) {
case AUTHOR_ID -> "id";
case AUTHOR_PSEUDO -> "pseudo";
default -> null;
};
} }
return criteriaBuilder.equal( if (value instanceof UUID) {
result = criteriaBuilder.equal(from.get(attributeName), value);
} else {
result = criteriaBuilder.equal(
criteriaBuilder.lower( criteriaBuilder.lower(
from.get(attributeName) from.get(attributeName)
), ),
value value
); );
} }
return result;
}
private Predicate mapContainsPredicate( private Predicate mapContainsPredicate(
CriteriaBuilder criteriaBuilder, CriteriaBuilder criteriaBuilder,
Root<PublicationEntity> fromPublication, Root<PublicationEntity> fromPublication,
PublicationSearchField searchField, PublicationSearchJpaField searchField,
Object value Object value
) { ) {
From<?, ?> from = fromPublication; From<?, ?> from = fromPublication;
@@ -1,9 +1,11 @@
package org.codiki.infrastructure.publication.repository; package org.codiki.infrastructure.publication.repository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.codiki.infrastructure.publication.model.PublicationEntity; import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -16,4 +18,12 @@ public interface PublicationRepository extends JpaRepository<PublicationEntity,
WHERE p.id = :publicationId WHERE p.id = :publicationId
""") """)
Optional<PublicationEntity> findById(@Param("publicationId") UUID publicationId); Optional<PublicationEntity> findById(@Param("publicationId") UUID publicationId);
@Query("""
SELECT p
FROM PublicationEntity p
JOIN FETCH p.author a
ORDER BY p.creationDate DESC
""")
List<PublicationEntity> getLatest(Limit limit);
} }
@@ -10,3 +10,27 @@ insert into user_role values
insert into category values insert into category values
('172fa901-3f4b-4540-92f3-1c15820e8ec9', 'Main category', null), ('172fa901-3f4b-4540-92f3-1c15820e8ec9', 'Main category', null),
('3f4b4540-a901-92f3-1c15-8ec9172f820e', 'Sub category', '172fa901-3f4b-4540-92f3-1c15820e8ec9'); ('3f4b4540-a901-92f3-1c15-8ec9172f820e', 'Sub category', '172fa901-3f4b-4540-92f3-1c15820e8ec9');
UPDATE public.category
SET parent_category_id='04347de5-2814-4aff-9fe9-51b34c1c743e'
WHERE id in (
'0f4c4d7c-2ccc-4725-88b6-672aa518da90',
'2cad9c28-ab5d-4c8f-b7da-70ff8bc02586'
);
UPDATE public.category
SET parent_category_id='3dec7c5a-e7d6-4b21-beb1-209cdf5be067'
WHERE id in (
'61f9fbf3-3340-4ea4-9661-04089377bb2e',
'753570cc-3403-4bac-b9da-6c19875d98b7',
'1515ff79-e42e-4d84-9496-6cdcf1cb74f2',
'b58bda0b-2f45-4c7a-8ece-1a206fb32a7a',
'49b4df8a-19f5-459b-b508-6b7c71332523'
);
UPDATE public.category
SET parent_category_id='41b2792e-6f65-48be-8718-82ac58101aa8'
WHERE id in (
'7234cd9e-3834-45c5-973b-1574f5c3c4c6',
'f46fb104-4f53-4732-b33b-6a3ef8c2c0a3'
);
@@ -36,6 +36,7 @@ CREATE INDEX category_parent_category_id_idx ON category (parent_category_id);
CREATE TABLE IF NOT EXISTS picture ( CREATE TABLE IF NOT EXISTS picture (
id UUID NOT NULL, id UUID NOT NULL,
publisher_id UUID NOT NULL, publisher_id UUID NOT NULL,
published_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT picture_pk PRIMARY KEY (id), CONSTRAINT picture_pk PRIMARY KEY (id),
CONSTRAINT picture_publisher_id_fk FOREIGN KEY (publisher_id) REFERENCES "user" (id) CONSTRAINT picture_publisher_id_fk FOREIGN KEY (publisher_id) REFERENCES "user" (id)
); );
@@ -49,6 +50,7 @@ CREATE TABLE IF NOT EXISTS publication (
key VARCHAR(14) NOT NULL, key VARCHAR(14) NOT NULL,
title VARCHAR NOT NULL, title VARCHAR NOT NULL,
text VARCHAR NOT NULL, text VARCHAR NOT NULL,
parsed_text VARCHAR,
description VARCHAR NOT NULL, description VARCHAR NOT NULL,
creation_date TIMESTAMP NOT NULL, creation_date TIMESTAMP NOT NULL,
illustration_id UUID NOT NULL, illustration_id UUID NOT NULL,
@@ -1,14 +1,17 @@
package org.codiki.infrastructure.publication; package org.codiki.infrastructure.publication;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.model.search.PublicationSearchField;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS; import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.KEY;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
class PublicationSearchCriteriaJpaAdapterTest { class PublicationSearchCriteriaJpaAdapterTest {
private PublicationSearchCriteriaJpaAdapter adapter; private PublicationSearchCriteriaJpaAdapter adapter;
@@ -24,15 +27,15 @@ class PublicationSearchCriteriaJpaAdapterTest {
void should_adapt_criteria_for_jpa() { void should_adapt_criteria_for_jpa() {
// given // given
List<PublicationSearchCriterion> initialCriteria = List.of( List<PublicationSearchCriterion> initialCriteria = List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "critère") new PublicationSearchCriterion(PublicationSearchField.KEY, CONTAINS, "critère")
); );
// when // when
List<PublicationSearchCriterion> result = adapter.adaptCriteriaForJpa(initialCriteria); List<PublicationSearchJpaCriterion> result = adapter.adaptCriteriaForJpa(initialCriteria);
// then // then
List<PublicationSearchCriterion> expectedResult = List.of( List<PublicationSearchJpaCriterion> expectedResult = List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "crit_re") new PublicationSearchJpaCriterion(PublicationSearchJpaField.KEY, CONTAINS, "crit_re")
); );
assertThat(result).isEqualTo(expectedResult); assertThat(result).isEqualTo(expectedResult);
} }
+11
View File
@@ -41,6 +41,17 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
</plugin> </plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>
@@ -1,7 +1,11 @@
application: application:
pictures: pictures:
path: /Users/florian_thierry/Documents/Developpement/codiki-hexa/pictures-folder/ path: /home/florian/Developpement/codiki-hexagonal/backend/pictures-folder/
temp-path : /Users/florian_thierry/Documents/Developpement/codiki-hexa/pictures-folder/temp/ temp-path : /home/florian/Developpement/codiki-hexagonal/backend/pictures-folder/temp/
logging:
level:
org.springframework.security: DEBUG
server: server:
port: 8987 port: 8987
@@ -9,10 +9,6 @@ application:
path: /opt/codiki/pictures/ path: /opt/codiki/pictures/
temp-path: /opt/codiki/pictures/temp/ temp-path: /opt/codiki/pictures/temp/
logging:
level:
org.springframework.security: DEBUG
server: server:
http2: http2:
enabled: true enabled: true
+8
View File
@@ -19,6 +19,7 @@
<java-jwt.version>4.4.0</java-jwt.version> <java-jwt.version>4.4.0</java-jwt.version>
<postgresql.version>42.7.0</postgresql.version> <postgresql.version>42.7.0</postgresql.version>
<tika-core.version>2.9.0</tika-core.version> <tika-core.version>2.9.0</tika-core.version>
<commons-lang3.version>3.14.0</commons-lang3.version>
</properties> </properties>
<modules> <modules>
@@ -78,6 +79,13 @@
<artifactId>tika-core</artifactId> <artifactId>tika-core</artifactId>
<version>${tika-core.version}</version> <version>${tika-core.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -0,0 +1,11 @@
meta {
name: Get latest
type: http
seq: 6
}
get {
url: {{url}}/api/publications/latest
body: none
auth: none
}
@@ -0,0 +1,21 @@
meta {
name: Preview content
type: http
seq: 7
}
post {
url: {{url}}/api/publications/preview
body: json
auth: bearer
}
auth:bearer {
token: {{bearerToken}}
}
body:json {
{
"text" : "[h1]Test[/h1]"
}
}
@@ -0,0 +1,11 @@
meta {
name: Refresh token
type: http
seq: 4
}
post {
url: {{url}}/api/users/refresh-token
body: none
auth: none
}
@@ -1,7 +1,7 @@
vars { vars {
url: http://localhost:8987 url: http://localhost:8987
publicationId: ec76602f-5501-4091-868e-b471611e63de publicationId: ec76602f-5501-4091-868e-b471611e63de
bearerToken: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1YWQ0NjJiOC04ZjllLTRhMjYtYmI4Ni1jNzRmZWY1ZDExYjYiLCJleHAiOjE3MTA4Mzc2ODQsInBzZXVkbyI6IlN0YW5kYXJkIHVzZXIiLCJlbWFpbCI6InN0YW5kYXJkLnVzZXJAY29kaWtpLm9yZyIsInJvbGVzIjoiU1RBTkRBUkQifQ.2HggC3T_4I14IpW02DZJiYfgYwc074kU8Y4AmuGf1mZzv0U8OUxpAw_xEhnKtn8NcaCozz_2vFv4o_CaBqS8Ag bearerToken: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkMWQ1NTdhNi04OGIxLTQyNzQtOTk0ZS1mOWE5YTYwOTc5OTciLCJleHAiOjE3MjY5NTExNTgsInBob3RvSWQiOiI2MjhkYTFhNy0wNzAyLTRlNDktOGIwNi00ZDg2MGE2YTNkZTUiLCJwc2V1ZG8iOiJUYWtpZ3VjaGkiLCJlbWFpbCI6ImZsb3JpYW4udGhpZXJyeTcyQGdtYWlsLmNvbSIsInJvbGVzIjoiU1RBTkRBUkQifQ.4OQglB0cT2hTMO7_Bfxj7nQPYi42e0Gh06jmHj2q-SQTM6Md70Ii_BiKR__GxY14bahPAjLcIWfAYS2A0Tc1Vw
categoryId: 172fa901-3f4b-4540-92f3-1c15820e8ec9 categoryId: 172fa901-3f4b-4540-92f3-1c15820e8ec9
pictureId: 65b660b7-66bb-4e4a-a62c-fd0ca101f972 pictureId: 65b660b7-66bb-4e4a-a62c-fd0ca101f972
} }
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
SERVER_CODIKI_ROOT_FOLDER='/home/florian/codiki-hexagonal'
SERVER_ADDRESS='192.168.0.153'
SERVER_USER='florian'
SERVER_PORT='22'
function handle_error() {
local resultCode=$1
if [[ $resultCode -ne 0 ]]
then
echo "$errorMessage"
echo
exit 1
else
echo 'Operation succeded.'
echo
fi
}
function change_configuration_files() {
echo 'Copy production configuration file of backend...'
cp ./ci/configuration/backend/application-prod.yml ./backend/codiki-launcher/src/main/resources/application-prod.yml
handle_error $?
}
function build_backend() {
echo 'Backend docker image building...'
docker build -t codiki-backend -f ./Dockerfile-backend . --no-cache
handle_error $?
}
function build_frontend() {
echo 'Frontend docker image building...'
docker build -t codiki-frontend -f ./Dockerfile-frontend . --no-cache
handle_error $?
}
function extract_docker_images() {
echo 'Extraction of backend docker image into an archive...'
docker save codiki-backend:latest -o ./ci/bin/codiki-backend.tar
handle_error $?
echo 'Extraction of frontend docker image into an archive...'
docker save codiki-frontend:latest -o ./ci/bin/codiki-frontend.tar
handle_error $?
}
function copy_docker_compose_file_in_bin_folder() {
echo 'Copy of docker compose file in bin folder...'
cp ./docker-compose.yml ./ci/bin/docker-compose.yml
handle_error $?
}
function upload_files_on_server() {
echo 'Sending of docker images on server'
scp -P $SERVER_PORT ./ci/bin/* $SERVER_USER@$SERVER_ADDRESS:$SERVER_CODIKI_ROOT_FOLDER/
handle_error $?
}
# execute_remote_command_on_server
function ercos() {
local command=$1
ssh -p $SERVER_PORT -l $SERVER_USER $SERVER_ADDRESS $command
handle_error $1
}
function deploy_docker_images_on_server() {
echo 'Import backend docker image archive on server...'
ercos "docker load < $SERVER_CODIKI_ROOT_FOLDER/codiki-backend.tar"
echo 'Import frontend docker image archive on server...'
ercos "docker load < $SERVER_CODIKI_ROOT_FOLDER/codiki-frontend.tar"
}
function restart_services() {
echo 'Stop services on server...'
ercos "cd $SERVER_CODIKI_ROOT_FOLDER && docker compose down"
echo 'Start services on server...'
ercos "cd $SERVER_CODIKI_ROOT_FOLDER && docker compose up --detach"
}
function main() {
change_configuration_files
build_backend
build_frontend
extract_docker_images
copy_docker_compose_file_in_bin_folder
upload_files_on_server
deploy_docker_images_on_server
restart_services
}
main
+83
View File
@@ -0,0 +1,83 @@
'use strict';
import fs from 'fs';
const englishI18nFilePath='../../frontend/src/locale/messages.json';
const frenchI18nFilePath='../../frontend/src/locale/messages-fr.json';
interface Translations {
[key: string]: string;
}
interface I18nFileContent {
locale: string;
translations: Translations;
};
interface Translation {
key: string;
value: string;
}
interface I18nTranslations {
locale: string;
translations: Translation[];
}
function readAndParseFile(filePath): I18nTranslations {
const fileContent = fs.readFileSync(filePath);
const parsedFileContent: I18nFileContent = JSON.parse(fileContent.toString());
return {
locale: parsedFileContent.locale,
translations: Object.keys(parsedFileContent.translations)
.map(translationKey => {
return {
key: translationKey,
value: parsedFileContent.translations[translationKey]
};
})
};
}
function findMatchingFrenchTranslation(englishTranslation: Translation, frenchI18nFileContent: I18nTranslations): Translation | undefined {
return frenchI18nFileContent.translations.find(frenchTranslation => frenchTranslation.key === englishTranslation.key);
}
function main(): void {
const englishI18nTranslations = readAndParseFile(englishI18nFilePath);
const frenchI18nTranslations = readAndParseFile(frenchI18nFilePath);
const frenchTranslations = englishI18nTranslations.translations
.map(englishTranslation => {
let result: Translation;
const matchingFrenchTranslation = findMatchingFrenchTranslation(englishTranslation, frenchI18nTranslations);
if (matchingFrenchTranslation?.value?.length) {
result = matchingFrenchTranslation;
} else {
result = {
key: englishTranslation.key,
value: `<À TRADUIRE> - ${englishTranslation.value}`
}
}
return result;
});
const newFrenchI18nTranslations: I18nTranslations = {
locale: 'fr-FR',
translations: frenchTranslations
};
const newFrenchTranslationFileContent: I18nFileContent = {
locale: newFrenchI18nTranslations.locale,
translations: newFrenchI18nTranslations.translations.reduce(
(result, translation) => Object.assign(result, { [translation.key]: translation.value }),
{}
)
}
fs.writeFileSync(frenchI18nFilePath, JSON.stringify(newFrenchTranslationFileContent));
}
main();
+540
View File
@@ -0,0 +1,540 @@
{
"name": "i18n-completer",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "i18n-completer",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@types/node": "^22.5.5",
"tsx": "^4.19.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
"integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
"integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
"integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
"integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
"integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
"integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
"integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
"integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
"integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
"integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
"integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
"integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
"integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
"integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
"integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
"integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
"integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
"integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
"integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
"integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
"integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
"integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
"integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.5.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz",
"integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/esbuild": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
"integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.23.1",
"@esbuild/android-arm": "0.23.1",
"@esbuild/android-arm64": "0.23.1",
"@esbuild/android-x64": "0.23.1",
"@esbuild/darwin-arm64": "0.23.1",
"@esbuild/darwin-x64": "0.23.1",
"@esbuild/freebsd-arm64": "0.23.1",
"@esbuild/freebsd-x64": "0.23.1",
"@esbuild/linux-arm": "0.23.1",
"@esbuild/linux-arm64": "0.23.1",
"@esbuild/linux-ia32": "0.23.1",
"@esbuild/linux-loong64": "0.23.1",
"@esbuild/linux-mips64el": "0.23.1",
"@esbuild/linux-ppc64": "0.23.1",
"@esbuild/linux-riscv64": "0.23.1",
"@esbuild/linux-s390x": "0.23.1",
"@esbuild/linux-x64": "0.23.1",
"@esbuild/netbsd-x64": "0.23.1",
"@esbuild/openbsd-arm64": "0.23.1",
"@esbuild/openbsd-x64": "0.23.1",
"@esbuild/sunos-x64": "0.23.1",
"@esbuild/win32-arm64": "0.23.1",
"@esbuild/win32-ia32": "0.23.1",
"@esbuild/win32-x64": "0.23.1"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz",
"integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.19.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.1.tgz",
"integrity": "sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.23.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true,
"license": "MIT"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "i18n-completer",
"version": "1.0.0",
"main": "app.ts",
"type": "module",
"scripts": {
"start": "npx tsx app.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/node": "^22.5.5",
"tsx": "^4.19.1"
}
}
+41
View File
@@ -0,0 +1,41 @@
services:
codiki-database:
image: "postgres:16"
container_name: "codiki-database"
ports:
- "50010:5432"
networks:
- "codiki-network"
environment:
POSTGRES_DB: codiki_db
POSTGRES_USER: codiki_admin
POSTGRES_PASSWORD: <POSTGRES_PASSWORD>
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- "./docker/postgresql/pgdata:/var/lib/postgresql/data/pgdata"
restart: always
codiki-backend:
image: codiki-backend:latest
container_name: "codiki-backend"
environment:
- "SPRING_PROFILES_ACTIVE=prod"
ports:
- "50011:8080"
volumes:
- "/opt/codiki/pictures:/opt/codiki/pictures"
networks:
- "codiki-network"
restart: always
codiki-frontend:
image: codiki-frontend:latest
container_name: "codiki-frontend"
ports:
- "50012:80"
networks:
- "codiki-network"
restart: always
networks:
codiki-network:
+63 -9
View File
@@ -3,7 +3,7 @@
"version": 1, "version": 1,
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"codiki-ng": { "codiki": {
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
@@ -13,11 +13,20 @@
"root": "", "root": "",
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "app", "prefix": "app",
"i18n": {
"sourceLocale": "en",
"locales": {
"fr": {
"translation": "src/locale/messages-fr.json"
}
}
},
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular-devkit/build-angular:application",
"options": { "options": {
"outputPath": "dist/codiki-ng", "outputPath": "dist/codiki",
"index": "src/index.html", "index": "src/index.html",
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": [ "polyfills": [
@@ -36,7 +45,7 @@
"scripts": [] "scripts": []
}, },
"configurations": { "configurations": {
"production": { "production-en": {
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
@@ -49,24 +58,66 @@
"maximumError": "4kb" "maximumError": "4kb"
} }
], ],
"outputHashing": "all" "outputHashing": "all",
"outputPath": "dist/codiki/en/"
},
"production-fr": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"localize": ["fr"],
"i18nMissingTranslation": "error",
"outputHashing": "all",
"outputPath": "dist/codiki/fr/"
}, },
"development": { "development": {
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true "sourceMap": true
},
"en": {
"outputPath": "dist/codiki/en/",
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"fr": {
"outputPath": "dist/codiki/fr/",
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"localize": ["fr"],
"i18nMissingTranslation": "warning"
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": ""
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"configurations": { "configurations": {
"production": { "production-en": {
"buildTarget": "codiki-ng:build:production" "buildTarget": "codiki:build:production-en"
},
"production-fr": {
"buildTarget": "codiki:build:production-fr"
}, },
"development": { "development": {
"buildTarget": "codiki-ng:build:development" "buildTarget": "codiki:build:development"
},
"en": {
"buildTarget": "codiki:build:en"
},
"fr": {
"buildTarget": "codiki:build:fr"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
@@ -74,7 +125,7 @@
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n", "builder": "@angular-devkit/build-angular:extract-i18n",
"options": { "options": {
"buildTarget": "codiki-ng:build" "buildTarget": "codiki:build"
} }
}, },
"test": { "test": {
@@ -99,5 +150,8 @@
} }
} }
} }
},
"cli": {
"analytics": false
} }
} }
+54
View File
@@ -0,0 +1,54 @@
events {
worker_connections 1024;
}
http {
# Browser preferred language detection (does NOT require AcceptLanguageModule)
map $http_accept_language $accept_language {
~*^fr fr;
~*^en en;
}
types {
module js;
}
include /etc/nginx/mime.types;
server {
listen 80;
server_name codiki.org;
root /usr/share/nginx/html;
# Fallback to default language if no preference defined by browser
if ($accept_language ~ "^$") {
set $accept_language "fr";
}
location ~ ^/$ {
# Redirect "/" to Angular app in browser's preferred language
rewrite ^/$ /$accept_language permanent;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Everything under the Angular app is always redirected to Angular in the correct language
location ~ ^/(fr|en)/ {
try_files $uri /$1/index.html?$args;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location ~ ^/api {
proxy_pass http://192.168.0.153:50011;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
+5626 -3722
View File
File diff suppressed because it is too large Load Diff
+25 -17
View File
@@ -3,31 +3,39 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --port 4201 --proxy-config proxy.conf.json", "start": "npm run start-en",
"start-en": "ng serve --port 4201 --configuration=en --proxy-config proxy.conf.json",
"start-fr": "ng serve --port 4201 --configuration=fr --proxy-config proxy.conf.json",
"build": "ng build", "build": "ng build",
"build-prod-en": "ng build --configuration=production-en --base-href /en/",
"build-prod-fr": "ng build --configuration=production-fr --base-href",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"i18n": "npm run i18n-ng-extraction && npm run i18n-fr-file-completion",
"i18n-ng-extraction": "ng extract-i18n --output-path src/locale --format=json",
"i18n-fr-file-completion": "cd ../ci/i18n-completer && npm start && cd -"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^18.2.5",
"@angular/cdk": "^17.3.1", "@angular/cdk": "^18.2.5",
"@angular/common": "^17.0.0", "@angular/common": "^18.2.5",
"@angular/compiler": "^17.0.0", "@angular/compiler": "^18.2.5",
"@angular/core": "^17.0.0", "@angular/core": "^18.2.5",
"@angular/forms": "^17.0.0", "@angular/forms": "^18.2.5",
"@angular/material": "^17.3.1", "@angular/material": "^18.2.5",
"@angular/platform-browser": "^17.0.0", "@angular/platform-browser": "^18.2.5",
"@angular/platform-browser-dynamic": "^17.0.0", "@angular/platform-browser-dynamic": "^18.2.5",
"@angular/router": "^17.0.0", "@angular/router": "^18.2.5",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.14.2" "zone.js": "~0.14.10"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^17.0.5", "@angular-devkit/build-angular": "^18.2.5",
"@angular/cli": "^17.0.5", "@angular/cli": "^18.2.5",
"@angular/compiler-cli": "^17.0.0", "@angular/compiler-cli": "^18.2.5",
"@angular/localize": "^18.2.5",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0", "jasmine-core": "~5.1.0",
"karma": "~6.4.0", "karma": "~6.4.0",
@@ -35,6 +43,6 @@
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.2.2" "typescript": "~5.5.4"
} }
} }
+4 -1
View File
@@ -1,2 +1,5 @@
<app-header></app-header> <app-header></app-header>
<router-outlet></router-outlet> <main>
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>
+6
View File
@@ -1,8 +1,14 @@
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
app-header { app-header {
width: 100%; width: 100%;
} }
main {
flex: 1;
padding: 1em 0;
}
} }
+3 -1
View File
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { HeaderComponent } from './components/header/header.component'; import { HeaderComponent } from './components/header/header.component';
import { FooterComponent } from './components/footer/footer.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -9,7 +10,8 @@ import { HeaderComponent } from './components/header/header.component';
imports: [ imports: [
CommonModule, CommonModule,
RouterOutlet, RouterOutlet,
HeaderComponent HeaderComponent,
FooterComponent
], ],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss' styleUrl: './app.component.scss'
+16 -6
View File
@@ -1,14 +1,24 @@
import { ApplicationConfig } from '@angular/core'; import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter, withRouterConfig } from '@angular/router';
import { routes } from './app.routes'; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient } from '@angular/common/http'; import { routes } from './app.routes';
import { JwtInterceptor } from './core/interceptor/jwt.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideRouter(routes), provideRouter(
routes,
withRouterConfig({
paramsInheritanceStrategy: 'always',
onSameUrlNavigation: 'reload'
})
),
provideAnimationsAsync(), provideAnimationsAsync(),
provideHttpClient() provideExperimentalZonelessChangeDetection(),
provideHttpClient(withInterceptorsFromDi()),
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
] ]
}; };
+36 -2
View File
@@ -1,6 +1,40 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'login', loadComponent: () => import('./pages/login/login.component').then(module => module.LoginComponent) }, {
{ path: '**', loadComponent: () => import('./pages/home/home.component').then(module => module.HomeComponent) } path: 'login',
loadComponent: () => import('./pages/login/login.component').then(module => module.LoginComponent)
},
{
path: 'signin',
loadComponent: () => import('./pages/signin/signin.component').then(module => module.SigninComponent)
},
{
path: 'disconnect',
loadComponent: () => import('./pages/disconnection/disconnection.component').then(module => module.DisconnectionComponent)
},
{
path: 'publications/new',
loadChildren: () => import('./pages/publication-creation/publication-creation.routes').then(module => module.ROUTES)
},
{
path: 'publications/:publicationId',
loadComponent: () => import('./pages/publication/publication.component').then(module => module.PublicationComponent)
},
{
path: 'publications/:publicationId/edit',
loadChildren: () => import('./pages/publication-update/publication-update.routes').then(module => module.ROUTES)
},
{
path: 'publications',
loadComponent: () => import('./pages/search-publications/search-publications.component').then(module => module.SearchPublicationsComponent)
},
{
path: 'my-publications',
loadChildren: () => import('./pages/my-publications/my-publications.routes').then(module => module.ROUTES)
},
{
path: '**',
loadComponent: () => import('./pages/home/home.component').then(module => module.HomeComponent)
}
]; ];
@@ -0,0 +1,10 @@
<h1>{{title}}</h1>
<h2>{{description}}</h2>
<footer>
<button type="button" class="secondary" (click)="closeDialog()" i18n>
No
</button>
<button type="button" (click)="closeAndValidate()" i18n>
Yes
</button>
</footer>
@@ -0,0 +1,39 @@
:host {
display: flex;
flex-direction: column;
text-align: center;
padding: 1em;
footer {
display: flex;
flex-direction: row;
justify-content: space-between;
button {
padding: .8em 1.2em;
border-radius: 10em;
border: none;
background-color: #3f51b5;
color: white;
transition: background-color .2s ease-in-out;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background-color: #5b6ed8;
}
&.secondary {
color: #3f51b5;
background-color: white;
&:hover {
background-color: #f2f4ff;
cursor: pointer;
}
}
}
}
}
@@ -0,0 +1,35 @@
import { Component, inject, Input } from "@angular/core";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
export interface ConfirmationDialogData {
title: string;
description: string;
}
@Component({
selector: 'app-confirmation-dialog',
standalone: true,
templateUrl: './confirmation-dialog.component.html',
styleUrl: './confirmation-dialog.component.scss',
imports: []
})
export class ConfirmationDialog {
private readonly dialogRef = inject(MatDialogRef<ConfirmationDialog>);
data: ConfirmationDialogData = inject(MAT_DIALOG_DATA);
get title(): string {
return this.data.title;
}
get description(): string {
return this.data.description;
}
closeAndValidate(): void {
this.dialogRef.close(true);
}
closeDialog(): void {
this.dialogRef.close(false);
}
}
@@ -0,0 +1,14 @@
<div i18n>
<span class="copy-left">&copy;</span>
2016 - 2024 All rights reserved
-
2.0-alpha
<a [routerLink]="['./']" matTooltip="Health checking will be available in future..." i18n-matTooltip>
<mat-icon>favorite</mat-icon>
</a>
</div>
<div>
<mat-icon matTooltip="Documentation will be available in future..." i18n-matTooltip>menu_book</mat-icon>
-
<span i18n>Development realised by</span> Florian THIERRY
</div>
@@ -0,0 +1,31 @@
:host {
background-color: #3f51b5;
color: rgba(255,255,255,.6);
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
padding: .5em;
font-size: 1.1em;
div {
display: flex;
flex-direction: row;
align-items: center;
.copy-left {
transform: rotate(180deg);
}
a {
text-decoration: none;
color: rgba(255,255,255,.6);
}
mat-icon {
font-size: 1em;
display: flex;
justify-content: center;
align-items: center;
}
}
}
@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-footer',
standalone: true,
imports: [MatIconModule, MatTooltipModule, RouterModule],
templateUrl: './footer.component.html',
styleUrl: './footer.component.scss'
})
export class FooterComponent {
}
@@ -1,16 +1,36 @@
<div> <div class="left">
<button type="button"> <button type="button" (click)="sideMenu.open()" matTooltip="Click to show side menu" i18n-matTooltip>
<mat-icon>menu</mat-icon> <mat-icon>menu</mat-icon>
</button> </button>
<a [routerLink]="['/home']">
<img src="assets/images/codiki.png" alt="logo"/> <img src="assets/images/codiki.png" alt="logo"/>
<span class="title">Codiki</span> <span class="title">Codiki</span>
</a>
</div> </div>
<div>
<input name="search-query" placeholder="Search something..." /> <div class="middle">
<button type="button"> <app-publications-search-bar></app-publications-search-bar>
<mat-icon>search</mat-icon> </div>
<div class="right">
@if (isAuthenticated) {
<button mat-button class="button" [matMenuTriggerFor]="authenticatedUserMenu">
<mat-icon>more_vert</mat-icon>
</button> </button>
<mat-menu #authenticatedUserMenu="matMenu">
<div class="authenticated-user-menu">
<a [routerLink]="['/my-publications']" matRipple i18n>
<mat-icon>description</mat-icon>
My publications
</a>
<a [routerLink]="['/disconnect']" matRipple class="disconnection" i18n>
<mat-icon>logout</mat-icon>
Disconnect
</a>
</div>
</mat-menu>
} @else {
<a [routerLink]="['/login']" class="button" matRipple i18n>Login</a>
}
</div> </div>
<div> <app-side-menu #sideMenu></app-side-menu>
<a [routerLink]="['/login']">Login</a>
</div>
@@ -7,23 +7,52 @@ $headerHeight: 3.5em;
background-color: #3f51b5; background-color: #3f51b5;
color: white; color: white;
position: relative; position: relative;
border: 1px solid black;
height: $headerHeight; height: $headerHeight;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
div { div {
border: 1px solid black;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
position: relative;
height: $headerHeight; height: $headerHeight;
&:nth-child(1) { &.left {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
align-items: center; align-items: center;
gap: 1em; gap: 1em;
padding: 0 1em; padding: 0 1em;
z-index: 2;
button {
background-color: #3f51b5;
color: white;
border: none;
border-radius: 10em;
transition: background-color .2s ease-in-out;
display: flex;
justify-content: center;
align-items: center;
$buttonSize: 2.5em;
width: $buttonSize;
height: $buttonSize;
&:hover {
cursor: pointer;
background-color: #5c6bc0;
}
}
a {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
color: white;
text-decoration: none;
gap: .5em;
img { img {
$imageSize: 2em; $imageSize: 2em;
@@ -33,42 +62,64 @@ $headerHeight: 3.5em;
.title { .title {
font-size: 1.5em; font-size: 1.5em;
display: none;
@media screen and (min-width: 600px) {
display: block;
}
}
} }
} }
&:nth-child(2) { &.middle {
flex: 1; flex: 1;
$borderRadiusValue: 10em; $borderRadiusValue: 10em;
position: relative;
input { transition: max-width .2s ease-in-out;
flex: 1;
width: 60%;
max-width: 50em;
border-radius: $borderRadiusValue 0 0 $borderRadiusValue;
background-color: white;
border: solid 1px #ccc;
margin: .5em 0;
padding: .2em .5em;
}
button {
display: flex; display: flex;
justify-content: center;
align-items: center; align-items: center;
border-radius: 0 $borderRadiusValue $borderRadiusValue 0; z-index: 1;
background-color: white;
border: solid 1px #ccc;
margin: .5em 0;
&:hover { app-publications-search-bar {
background-color: #eee; width: 100%;
max-width: 12em;
@media screen and (min-width: 435px) {
max-width: 16em;
}
@media screen and (min-width: 500px) {
max-width: 20em;
}
@media screen and (min-width: 700px) {
max-width: 24em;
}
@media screen and (min-width: 800px) {
max-width: 32em;
}
@media screen and (min-width: 900px) {
max-width: 38em;
}
@media screen and (min-width: 1000px) {
max-width: 45em;
}
@media screen and (min-width: 1100px) {
max-width: 50em;
} }
} }
} }
&:nth-child(3) { &.right {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
z-index: 2;
a { a {
display: flex; display: flex;
@@ -76,6 +127,75 @@ $headerHeight: 3.5em;
align-items: center; align-items: center;
min-width: 5em; min-width: 5em;
color: white; color: white;
margin: 0.5em 0.5em;
border-radius: 10em;
text-decoration: none;
padding: 0 .8em;
background-color: #3f51b5;
transition: background-color .2s ease-in-out;
&:hover {
background-color: #5c6bc0;
}
}
button {
display: flex;
justify-content: center;
align-items: center;
min-width: 2.5em;
color: white;
margin: 0.5em 0.5em;
border-radius: 10em;
text-decoration: none;
padding: 0;
background-color: #3f51b5;
transition: background-color .2s ease-in-out;
&:hover {
background-color: #5c6bc0;
}
mat-icon {
margin: 0;
}
}
}
}
}
app-side-menu {
height: 100%;
}
.authenticated-user-menu {
display: flex;
flex-direction: column;
padding: 0.2em 0;
a {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
text-decoration: none;
background-color: white;
color: black;
padding: 1em;
gap: .5em;
transition: background-color .2s ease-in-out, color .2s ease-in-out;
&:hover {
background-color: #5c6bc0;
color: white;
}
&.disconnection {
color: #D50000;
&:hover {
background-color: #E53935;
color: white;
} }
} }
} }
@@ -1,13 +1,39 @@
import { Component } from '@angular/core'; import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon'; import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatRippleModule } from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AuthenticationService } from '../../core/service/authentication.service';
import { PublicationsSearchBarComponent } from '../publications-search-bar/publications-search-bar.component';
import { SideMenuComponent } from '../side-menu/side-menu.component';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
standalone: true, standalone: true,
imports: [MatButtonModule, MatIconModule, RouterModule], imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatRippleModule,
MatTooltipModule,
PublicationsSearchBarComponent,
ReactiveFormsModule,
RouterModule,
SideMenuComponent,
],
templateUrl: './header.component.html', templateUrl: './header.component.html',
styleUrl: './header.component.scss', styleUrl: './header.component.scss',
}) })
export class HeaderComponent {} export class HeaderComponent {
private authenticationService = inject(AuthenticationService);
searchControl = new FormControl('');
get isAuthenticated(): boolean {
return this.authenticationService.isAuthenticated();
}
}
@@ -0,0 +1,32 @@
<button type="button" class="close" (click)="closeDialog()">
<mat-icon>close</mat-icon>
</button>
<header>
<h1 i18n>Add a code block</h1>
</header>
<form [formGroup]="formGroup" (submit)="closeAndValidate()" ngNativeValidate>
<div class="form-content">
<mat-form-field>
<mat-label i18n>Programming language</mat-label>
<mat-select #programmingLanguageSelect formControlName="programmingLanguage">
@for(programmingLanguage of programmingLanguages; track programmingLanguage) {
<mat-option [value]="programmingLanguage.code">
{{programmingLanguage.label}}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label i18n>Code block</mat-label>
<textarea matInput formControlName="codeBlock"></textarea>
</mat-form-field>
</div>
<div class="actions">
<button type="submit" i18n>
Validate
</button>
<button type="button" (click)="closeDialog()" class="secondary" i18n>
Cancel
</button>
</div>
</form>
@@ -0,0 +1,78 @@
:host {
display: flex;
flex-direction: column;
padding: 1em;
gap: 1em;
position: relative;
max-height: 90vh;
.close {
position: absolute;
top: 1em;
right: 1em;
width: 2.5em;
height: 2.5em;
border-radius: 10em;
border: none;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
header {
flex: 1;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
form {
div {
&.form-content {
mat-form-field {
width: 100%;
textarea {
height: 30vh;
}
}
}
&.actions {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
button {
padding: .8em 1.2em;
border-radius: 10em;
border: none;
background-color: #3f51b5;
color: white;
transition: background-color .2s ease-in-out;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background-color: #5b6ed8;
}
&.secondary {
color: #3f51b5;
background-color: white;
&:hover {
background-color: #f2f4ff;
cursor: pointer;
}
}
}
}
}
}
}
@@ -0,0 +1,116 @@
import { Component, inject } from "@angular/core";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { MatDialogRef } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatIcon } from "@angular/material/icon";
import { MatInputModule } from "@angular/material/input";
import { MatSelectModule } from '@angular/material/select';
export interface ProgramingLanguage {
code: string;
label: string;
}
export const PROGRAMMING_LANGUAGES: ProgramingLanguage[] = [
{
code: 'bash',
label: 'Bash'
},
{
code: 'c',
label: 'C'
},
{
code: 'cpp',
label: 'C++'
},
{
code: 'cs',
label: 'C#'
},
{
code: 'lua',
label: 'Lua'
},
{
code: 'java',
label: 'Java'
},
{
code: 'json5',
label: 'JSON'
},
{
code: 'kt',
label: 'Kotlin'
},
{
code: 'markup',
label: 'html/xml'
},
{
code: 'php',
label: 'PHP'
},
{
code: 'plsql',
label: 'PL/SQL'
},
{
code: 'python',
label: 'Python'
},
{
code: 'powershell',
label: 'PowerShell'
},
{
code: 'rust',
label: 'Rust'
},
{
code: 'sql',
label: 'SQL'
},
{
code: 'ts',
label: 'Typescript'
},
{
code: 'yml',
label: 'YAML'
},
];
@Component({
selector: 'app-code-block-dialog',
standalone: true,
templateUrl: './code-block-dialog.component.html',
styleUrl: './code-block-dialog.component.scss',
imports: [
MatFormFieldModule,
MatIcon,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
]
})
export class CodeBlockDialog {
private readonly dialogRef = inject(MatDialogRef<CodeBlockDialog>);
private formBuilder = inject(FormBuilder);
programmingLanguages = PROGRAMMING_LANGUAGES;
formGroup = this.formBuilder.group({
programmingLanguage: new FormControl('', Validators.required),
codeBlock: new FormControl('', Validators.required)
});
closeAndValidate(): void {
if (this.formGroup.valid) {
this.dialogRef.close(this.formGroup.value);
}
}
closeDialog(): void {
this.dialogRef.close();
}
}
@@ -0,0 +1,30 @@
<button type="button" class="close" (click)="closeDialog()">
<mat-icon>close</mat-icon>
</button>
<header>
<h1 i18n>Select an illustration</h1>
</header>
<div class="picture-container">
@if (isLoading) {
<h2 i18n>Pictures loading...</h2>
<mat-spinner></mat-spinner>
} @else {
@if (pictures.length) {
@for(picture of pictures; track picture) {
<img src="/api/pictures/{{picture.id}}" (click)="selectPicture(picture)" matTooltip="Choose this illustration" i18n-matTooltip/>
}
} @else {
<h2 i18n>There is no any picture.</h2>
}
}
</div>
<footer>
<button type="button" class="secondary" matRipple (click)="closeDialog()" i18n>
Cancel
</button>
<button type="button" (click)="fileUpload.click()" matRipple i18n>
<mat-icon>upload_file</mat-icon>
Add new picture
</button>
<input type="file" (change)="uploadPicture($event)" #fileUpload/>
</footer>
@@ -0,0 +1,97 @@
:host {
display: flex;
flex-direction: column;
padding: 1em;
gap: 1em;
position: relative;
max-height: 90vh;
.close {
position: absolute;
top: 1em;
right: 1em;
width: 2.5em;
height: 2.5em;
border-radius: 10em;
border: none;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
header {
flex: 1;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.picture-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 1em;
max-height: 30em;
overflow-y: auto;
min-height: 10em;
padding: .5em 0;
img {
width: 15em;
height: 10em;
object-fit: cover;
border-radius: 1em;
opacity: .9;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
transition: opacity .2s ease-in-out, box-shadow .2s ease-in-out;
&:hover {
cursor: pointer;
opacity: 1;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.32),0 2px 10px 0 rgba(0,0,0,.24);
}
}
}
footer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
button {
padding: .8em 1.2em;
border-radius: 10em;
border: none;
background-color: #3f51b5;
color: white;
transition: background-color .2s ease-in-out;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background-color: #5b6ed8;
}
&.secondary {
color: #3f51b5;
background-color: white;
&:hover {
background-color: #f2f4ff;
cursor: pointer;
}
}
}
input[type=file] {
display: none;
}
}
}
@@ -0,0 +1,70 @@
import { Component, inject, OnInit } from "@angular/core";
import { Picture } from "../../../core/rest-services/picture/model/picture";
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import { MatSnackBar } from "@angular/material/snack-bar";
import { PictureRestService } from "../../../core/rest-services/picture/picture.rest-service";
import { MatIcon } from "@angular/material/icon";
import { MatDialogRef } from "@angular/material/dialog";
import {MatRippleModule} from '@angular/material/core';
import { MatTooltip } from "@angular/material/tooltip";
@Component({
selector: 'app-picture-selection',
standalone: true,
imports: [MatProgressSpinnerModule, MatIcon, MatRippleModule, MatTooltip],
templateUrl: './picture-selection-dialog.component.html',
styleUrl: './picture-selection-dialog.component.scss',
})
export class PictureSelectionDialog implements OnInit {
private readonly pictureRestService = inject(PictureRestService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogRef = inject(MatDialogRef<PictureSelectionDialog>);
isLoading: boolean = false;
isLoaded: boolean = false;
pictures: Picture[] = [];
ngOnInit(): void {
this.isLoading = true;
this.pictureRestService.getAllOfCurrentUser()
.then(pictures => {
this.pictures = pictures;
})
.catch(error => {
if (error.status === 401) {
this.dialogRef.close();
} else {
const errorMessage = $localize`An error occured while loading pictures.`;
console.error(errorMessage, error);
this.snackBar.open(errorMessage, $localize`Close`, { duration: 5000 });
}
})
.finally(() => {
this.isLoading = false;
this.isLoaded = true;
});
}
selectPicture(picture: Picture): void {
this.dialogRef.close(picture.id);
}
closeDialog(): void {
this.dialogRef.close();
}
uploadPicture(fileSelectionEvent: any): void {
const pictureFile = fileSelectionEvent.target.files[0];
if (pictureFile) {
this.pictureRestService.uploadPicture(pictureFile)
.then(pictureId => {
this.dialogRef.close(pictureId);
})
.catch(error => {
const errorMessage = $localize`A technical error occured while uploading your picture.`;
console.error(errorMessage, error);
this.snackBar.open(errorMessage, $localize`Close`, { duration: 5000 });
});
}
}
}
@@ -0,0 +1,24 @@
import { inject, Injectable } from "@angular/core";
import { PictureRestService } from "../../../core/rest-services/picture/picture.rest-service";
import { MatSnackBar } from "@angular/material/snack-bar";
import { MatDialogRef } from "@angular/material/dialog";
import { PictureSelectionDialog } from "./picture-selection-dialog.component";
@Injectable()
export class PictureSelectionDialogService {
private pictureRestService = inject(PictureRestService);
private snackBar = inject(MatSnackBar);
private readonly dialogRef = inject(MatDialogRef<PictureSelectionDialog>);
uploadPicture(pictureFile: File): void {
this.pictureRestService.uploadPicture(pictureFile)
.then(pictureId => {
this.dialogRef.close(pictureId);
})
.catch(error => {
const errorMessage = $localize`An error occured while uploading a picture...`;
console.error(errorMessage, error);
this.snackBar.open(errorMessage, $localize`Close`, { duration: 5000 });
});
}
}
@@ -0,0 +1,100 @@
<form [formGroup]="publicationEditionForm" (submit)="save()" ngNativeValidate>
<header>
<h1>{{title}}</h1>
</header>
<mat-tab-group dynamicHeight (selectedIndexChange)="onTabChange($event)">
<mat-tab label="Edition" i18n-label>
<div class="form-content">
<div class="first-part">
<div>
<mat-form-field>
<mat-label i18n>Title</mat-label>
<input matInput type="text" formControlName="title" />
</mat-form-field>
<mat-form-field>
<mat-label i18n>Description</mat-label>
<input matInput type="text" formControlName="description" />
</mat-form-field>
<mat-form-field>
<mat-label i18n>Category</mat-label>
<mat-select formControlName="categoryId">
@for (category of categories$ | async; track category) {
<mat-option [value]="category.id">
{{ category.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="picture-container">
<img [src]="publication.illustrationId.length ? '/api/pictures/' + publication.illustrationId : '/assets/images/default-picture.png'"
(click)="displayPictureSectionDialog()"
matTooltip="Click to change illustration"
i18n-matTooltip/>
</div>
</div>
<div class="actions">
<button type="button" matTooltip="Click to insert a title 1 section" (click)="insertTitle(1)" i18n-matTooltip>
H1
</button>
<button type="button" matTooltip="Click to insert a title 2 section" (click)="insertTitle(2)" i18n-matTooltip>
H2
</button>
<button type="button" matTooltip="Click to insert a title 3 section" (click)="insertTitle(3)" i18n-matTooltip>
H3
</button>
<button type="button" matTooltip="Click to insert a picture" (click)="selectAPicture()" i18n-matTooltip>
<mat-icon>image</mat-icon>
</button>
<button type="button" matTooltip="Click to insert a link" (click)="insertLink()" i18n-matTooltip>
<mat-icon>link</mat-icon>
</button>
<button type="button" matTooltip="Click to insert a code block" (click)="displayCodeBlockDialog()" i18n-matTooltip>
<mat-icon>code</mat-icon>
</button>
<button type="button" disabled matTooltip="Click to display editor help" i18n-matTooltip>
<mat-icon>help</mat-icon>
</button>
</div>
<mat-form-field>
<mat-label i18n>Content</mat-label>
<textarea
#textArea
matInput
formControlName="text"
class="text-input"
(keyup)="updateCursorPosition($event)"
(click)="updateCursorPosition($event)">
</textarea>
</mat-form-field>
</div>
</mat-tab>
<mat-tab label="Previewing" i18n-label>
<div class="preview">
@if ((isPreviewing$ | async) === true) {
<div class="preview-loading">
<h2 i18n>Preview is loading...</h2>
<mat-spinner></mat-spinner>
</div>
} @else {
<img class="illustration" src="/api/pictures/{{ publication.illustrationId }}" />
<header>
<h1>{{ publication.title }}</h1>
<h2>{{ publication.description }}</h2>
</header>
<main [innerHTML]="publicationInEdition.parsedText"></main>
}
</div>
</mat-tab>
</mat-tab-group>
<footer>
<app-submit-button label="Save" [requestPending]="!!(isSaving$ | async)" i18n-label></app-submit-button>
<button type="button" class="secondary" (click)="goPreviousLocation()" i18n>
Cancel
</button>
</footer>
</form>
@@ -0,0 +1,184 @@
:host {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
form {
margin: 1em;
max-width: 80em;
width: 90%;
border-radius: .5em;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
& > header {
padding: 2em;
background-color: #3f51b5;
color: white;
border-radius: .5em .5em 0 0;
h1 {
font-size: 2em;
margin-bottom: .5em;
}
}
footer {
padding: 2em;
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
}
}
}
.form-content {
padding: 2em;
padding-bottom: 0;
display: flex;
flex-direction: column;
gap: .5em;
mat-form-field {
textarea {
height: 20em;
}
}
.first-part {
display: flex;
flex-direction: column-reverse;
gap: 1em;
@media screen and (min-width: 600px) {
flex-direction: row;
div {
flex: 1 0;
&.picture-container {
max-width: 20em;
img {
max-height: 15em;
max-width: 20em;
}
}
}
}
div {
flex: 1 0 50%;
display: flex;
flex-direction: column;
justify-content: center;
&.picture-container {
img {
flex: 1;
object-fit: cover;
width: 100%;
cursor: pointer;
border-radius: 1em;
opacity: .9;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
transition: opacity .2s ease-in-out, box-shadow .2s ease-in-out;
&:hover {
cursor: pointer;
opacity: 1;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.32),0 2px 10px 0 rgba(0,0,0,.24);
}
}
}
}
}
.actions {
display: flex;
flex-direction: row;
gap: .5em;
button {
padding: 0;
display: flex;
justify-content: center;
align-items: center;
width: 3em;
height: 3em;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
font-weight: bold;
}
}
}
button, a.button {
padding: .8em 1.2em;
border-radius: 10em;
border: none;
background-color: #3f51b5;
color: white;
transition: background-color .2s ease-in-out;
&:hover {
background-color: #5b6ed8;
cursor: pointer;
}
&:disabled {
background-color: #5f6aa6;
cursor: not-allowed;
}
&.secondary {
color: #3f51b5;
background-color: white;
&:hover {
background-color: #f2f4ff;
cursor: pointer;
}
}
}
.preview {
display: flex;
flex-direction: column;
max-height: 80vh;
overflow-y: auto;
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
}
.illustration {
flex: 1;
height: 12em;
object-fit: cover;
transition: height .2s ease-in-out;
@media screen and (min-width: 450px) {
height: 15em;
}
@media screen and (min-width: 600px) {
height: 20em;
}
@media screen and (min-width: 750px) {
height: 25em;
}
}
header {
padding: 2em;
}
main {
padding: 2em;
text-align: justify;
}
}
@@ -0,0 +1,191 @@
import { CommonModule, Location } from "@angular/common";
import { Component, EventEmitter, inject, Input, OnChanges, OnDestroy, Output } from "@angular/core";
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { MatDialogModule } from "@angular/material/dialog";
import { MatIconModule } from "@angular/material/icon";
import { MatInputModule } from "@angular/material/input";
import { MatProgressSpinnerModule } from "@angular/material/progress-spinner";
import { MatSelectModule } from "@angular/material/select";
import { MatTabsModule } from "@angular/material/tabs";
import { MatTooltipModule } from "@angular/material/tooltip";
import { map, Observable, of, Subscription } from "rxjs";
import { Category } from "../../core/rest-services/category/model/category";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { CategoryService } from "../../core/service/category.service";
import { SubmitButtonComponent } from "../submit-button/submit-button.component";
import { PictureSelectionDialog } from "./picture-selection-dialog/picture-selection-dialog.component";
import { PublicationEditionService } from "./publication-edition.service";
@Component({
selector: 'app-publication-edition',
standalone: true,
templateUrl: './publication-edition.component.html',
styleUrl: './publication-edition.component.scss',
imports: [
CommonModule,
MatDialogModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
MatTabsModule,
MatTooltipModule,
PictureSelectionDialog,
ReactiveFormsModule,
SubmitButtonComponent
],
providers: [PublicationEditionService]
})
export class PublicationEditionComponent implements OnChanges, OnDestroy {
@Input()
publication!: Publication;
@Input()
title!: string;
@Input()
isSaving$: Observable<boolean> = of(false);
@Output()
publicationSave = new EventEmitter<Publication>();
publicationInEdition!: Publication;
private readonly categoryService = inject(CategoryService);
private readonly formBuilder = inject(FormBuilder);
private readonly location = inject(Location);
private readonly publicationEditionService = inject(PublicationEditionService);
private subscriptions: Subscription[] = [];
publicationEditionForm: FormGroup = this.formBuilder.group({
title: new FormControl<string | undefined>('', [Validators.required]),
description: new FormControl<string | undefined>('', [Validators.required]),
text: new FormControl<string | undefined>('', [Validators.required]),
illustrationId: new FormControl<string | undefined>('', [Validators.required]),
categoryId: new FormControl<string | undefined>('', [Validators.required])
});
get isLoading$(): Observable<boolean> {
return this.publicationEditionService.isLoading$;
}
get isPreviewing$(): Observable<boolean> {
return this.publicationEditionService.isPreviewing$;
}
get categories$(): Observable<Category[]> {
return this.categoryService.categories$
.pipe(
map(categories =>
categories.filter(category => category.subCategories.length == 0)
.sort(this.byNameAscComparator())
)
);
}
private byNameAscComparator(): (categoryA: Category, categoryB: Category) => number {
return (categoryA, categoryB) => this.compareStrings(categoryA.name, categoryB.name);
}
private compareStrings(stringA: string, stringB: string): number {
if (stringA < stringB) {
return -1;
}
if (stringA > stringB) {
return 1;
}
return 0;
}
ngOnChanges(): void {
this.ngOnDestroy();
this.publicationInEdition = this.publication;
this.publicationEditionService.init(this.publicationInEdition);
['title', 'description', 'text'].forEach(fieldName => {
const fieldSubscription = this.publicationEditionForm.controls[fieldName].valueChanges
.pipe(
map(value => value?.length ? value as string : '')
)
.subscribe(fieldValue => {
switch (fieldName) {
case 'title':
this.publicationEditionService.editTitle(fieldValue);
break;
case 'description':
this.publicationEditionService.editDescription(fieldValue);
break;
case 'text':
this.publicationEditionService.editText(fieldValue);
break;
default:
break;
}
});
this.subscriptions.push(fieldSubscription);
});
const categoryIdChangeSubscription = this.publicationEditionForm.controls['categoryId'].valueChanges
.subscribe(newCategoryId => {
this.publicationEditionService.editCategoryId(newCategoryId);
});
this.subscriptions.push(categoryIdChangeSubscription);
const publicationSubscription = this.publicationEditionService.state$.subscribe(state => {
console.log(state.publication.parsedText.substring(0, 15));
this.publicationInEdition = state.publication;
this.publicationEditionForm.controls['title'].setValue(this.publicationInEdition.title, { emitEvent: false });
this.publicationEditionForm.controls['description'].setValue(this.publicationInEdition.description, { emitEvent: false });
this.publicationEditionForm.controls['text'].setValue(this.publicationInEdition.text, { emitEvent: false });
this.publicationEditionForm.controls['illustrationId'].setValue(this.publicationInEdition.illustrationId, { emitEvent: false });
this.publicationEditionForm.controls['categoryId'].setValue(this.publicationInEdition.categoryId, { emitEvent: false });
});
this.subscriptions.push(publicationSubscription);
}
ngOnDestroy(): void {
this.subscriptions.forEach(subscription => subscription?.unsubscribe());
}
goPreviousLocation(): void {
this.location.back();
}
insertTitle(titleNumber: number): void {
this.publicationEditionService.insertTitle(titleNumber);
}
selectAPicture(): void {
this.publicationEditionService.selectAPicture();
}
insertLink(): void {
this.publicationEditionService.insertLink();
}
displayCodeBlockDialog(): void {
this.publicationEditionService.displayCodeBlockDialog();
}
save(): void {
this.publicationSave.emit(this.publicationInEdition);
}
displayPictureSectionDialog(): void {
this.publicationEditionService.displayPictureSectionDialog();
}
updateCursorPosition(event: KeyboardEvent | MouseEvent): void {
if (event.target) {
const textarea = event.target as HTMLTextAreaElement;
const positionStart = textarea.selectionStart;
const positionEnd = textarea.selectionEnd;
this.publicationEditionService.editCursorPosition(positionStart, positionEnd);
}
}
onTabChange(tabSelectedIndex: number): void {
if (tabSelectedIndex === 1) {
this.publicationEditionService.loadPreview();
}
}
}
@@ -0,0 +1,288 @@
import { Location } from "@angular/common";
import { inject, Injectable, OnDestroy } from "@angular/core";
import { MatDialog } from "@angular/material/dialog";
import { MatSnackBar } from "@angular/material/snack-bar";
import { ActivatedRoute } from "@angular/router";
import { BehaviorSubject, Observable, Subscription } from "rxjs";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { PublicationRestService } from "../../core/rest-services/publications/publication.rest-service";
import { copy } from "../../core/utils/ObjectUtils";
import { CodeBlockDialog } from "./code-block-dialog/code-block-dialog.component";
import { PictureSelectionDialog } from "./picture-selection-dialog/picture-selection-dialog.component";
import { PreviewContentRequest } from "../../core/rest-services/publications/model/preview";
declare let Prism: any;
export class CursorPosition {
start: number;
end: number;
selectedCharacters: number;
constructor(start: number, end: number) {
this.start = start;
this.end = end;
this.selectedCharacters = end - start;
}
}
export interface PublicationEditionState {
publication: Publication;
cursorPosition: CursorPosition;
}
const DEFAULT_PUBLICATION: Publication = {
id: '',
key: '',
title: '',
text: '',
parsedText: '',
description: '',
creationDate: new Date(),
illustrationId: '',
categoryId: '',
author: {
id: '',
name: '',
image: ''
}
};
const DEFAULT_CURSOR_POSITION = new CursorPosition(0, 0);
const DEFAULT_STATE: PublicationEditionState = {
publication: DEFAULT_PUBLICATION,
cursorPosition: DEFAULT_CURSOR_POSITION
};
@Injectable()
export class PublicationEditionService implements OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly publicationRestService = inject(PublicationRestService);
private readonly location = inject(Location);
private readonly snackBar = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
private isLoadingSubject = new BehaviorSubject<boolean>(false);
private stateSubject = new BehaviorSubject<PublicationEditionState>(copy(DEFAULT_STATE));
private subscriptions: Subscription[] = [];
private isSavingSubject = new BehaviorSubject<boolean>(false);
private isPreviewingSubject = new BehaviorSubject<boolean>(false);
ngOnDestroy(): void {
this.subscriptions.forEach(subscription => subscription.unsubscribe());
}
private get _state(): PublicationEditionState {
return this.stateSubject.value;
}
private _save(state: PublicationEditionState): void {
this.stateSubject.next(state);
}
get isLoading$(): Observable<boolean> {
return this.isLoadingSubject.asObservable();
}
get isSaving$(): Observable<boolean> {
return this.isSavingSubject.asObservable();
}
get isPreviewing$(): Observable<boolean> {
return this.isPreviewingSubject.asObservable();
}
get state$(): Observable<PublicationEditionState> {
return this.stateSubject.asObservable();
}
loadPublication(): void {
this.isLoadingSubject.next(true);
this.activatedRoute.paramMap.subscribe(params => {
const publicationId = params.get('publicationId');
if (publicationId == undefined) {
this.snackBar.open($localize`A technical error occurred while loading publication data.`, $localize`Close`, { duration: 5000 });
this.location.back();
} else {
this.publicationRestService.getById(publicationId)
.then(publication => {
const state = this._state;
state.publication = publication;
this.stateSubject.next(state);
})
.catch(error => {
const errorMessage = $localize`A technical error occurred while loading publication data.`;
this.snackBar.open(errorMessage, $localize`Close`, {duration: 5000});
console.error(errorMessage, error)
})
.finally(() => this.isLoadingSubject.next(false));
}
});
}
init(publication: Publication): void {
const state = this._state;
state.publication = publication;
this.stateSubject.next(state);
}
editTitle(title: string): void {
const state = this._state;
state.publication.title = title;
this._save(state);
}
editDescription(description: string): void {
const state = this._state;
state.publication.description = description;
this._save(state);
}
editCategoryId(categoryId: string): void {
const state = this._state;
state.publication.categoryId = categoryId;
this._save(state);
}
editText(text: string): void {
const state = this._state;
state.publication.text = text;
this._save(state);
}
editIllustrationId(pictureId: string): void {
const state = this._state;
state.publication.illustrationId = pictureId
this._save(state);
}
displayPictureSectionDialog(): void {
const dialogRef = this.dialog.open(PictureSelectionDialog);
const afterDialogCloseSubscription = dialogRef.afterClosed()
.subscribe(newPictureId => {
if (newPictureId) {
this.editIllustrationId(newPictureId);
}
});
this.subscriptions.push(afterDialogCloseSubscription);
}
displayCodeBlockDialog(): void {
const dialogRef = this.dialog.open(CodeBlockDialog, { width: '60em' });
const afterDialogCloseSubscription = dialogRef.afterClosed()
.subscribe(codeBlockWithLanguage => {
if (codeBlockWithLanguage) {
this.insertCodeBlock(codeBlockWithLanguage.programmingLanguage, codeBlockWithLanguage.codeBlock);
}
});
this.subscriptions.push(afterDialogCloseSubscription);
}
editCursorPosition(positionStart: number, positionEnd: number): void {
const state = this._state;
state.cursorPosition.start = positionStart;
state.cursorPosition.end = positionEnd;
this._save(state);
}
insertTitle(titleNumber: number): void {
if (titleNumber >= 1 && titleNumber <= 3) {
const state = this._state;
const publication = state.publication;
const publicationTextLeftPart = publication.text.substring(0, state.cursorPosition.start);
const publicationTextMiddlePart = publication.text.substring(state.cursorPosition.start, state.cursorPosition.end);
const publicationTextRightPart = publication.text.substring(state.cursorPosition.end);
const textWithTags = `${publicationTextLeftPart}[h${titleNumber}]${publicationTextMiddlePart}[/h${titleNumber}]${publicationTextRightPart}`;
publication.text = textWithTags;
this._save(state);
} else {
console.error(`Bad value for parameter of function 'insertTitle': '${titleNumber}'.`);
}
}
selectAPicture(): void {
const dialogRef = this.dialog.open(PictureSelectionDialog);
const afterDialogCloseSubscription = dialogRef.afterClosed()
.subscribe(newPictureId => {
if (newPictureId) {
this.insertPicture(newPictureId);
}
});
this.subscriptions.push(afterDialogCloseSubscription);
}
insertPicture(pictureId: string): void {
const state = this._state;
const publication = state.publication;
const publicationTextLeftPart = publication.text.substring(0, state.cursorPosition.start);
const publicationTextRightPart = publication.text.substring(state.cursorPosition.start);
const textWithTags = `${publicationTextLeftPart}[img src="/api/pictures/${pictureId}" /]${publicationTextRightPart}`;
publication.text = textWithTags;
this._save(state);
}
insertLink(): void {
const state = this._state;
const publication = state.publication;
const publicationTextLeftPart = publication.text.substring(0, state.cursorPosition.start);
const publicationTextMiddlePart = publication.text.substring(state.cursorPosition.start, state.cursorPosition.end);
const publicationTextRightPart = publication.text.substring(state.cursorPosition.end);
const textWithTags = `${publicationTextLeftPart}[link href="" txt="${publicationTextMiddlePart}" /]${publicationTextRightPart}`;
publication.text = textWithTags;
this._save(state);
}
insertCodeBlock(programmingLanguage: string, codeBlock: string): void {
const state = this._state;
const publication = state.publication;
const publicationTextLeftPart = publication.text.substring(0, state.cursorPosition.start);
const publicationTextRightPart = publication.text.substring(state.cursorPosition.start);
const codeBlockInstruction = `\n[code lg="${programmingLanguage}"]\n${codeBlock}\n[/code]\n\n`;
const textWithTags = `${publicationTextLeftPart}${codeBlockInstruction}${publicationTextRightPart}`;
publication.text = textWithTags;
this._save(state);
}
loadPreview(): void {
const state = this._state;
this.isPreviewingSubject.next(true);
const request: PreviewContentRequest = {
text: state.publication.text
};
this.publicationRestService.preview(request)
.then(response => {
state.publication.parsedText = response.text;
this._save(state);
setTimeout(() => Prism.highlightAll(), 1000);
})
.catch(error => {
console.error(error);
})
.finally(() => {
this.isPreviewingSubject.next(false);
});
}
}
@@ -0,0 +1,16 @@
@for(publication of publications$ | async; track publication) {
<a [routerLink]="['/publications/' + publication.id]" class="publication">
<img src="/api/pictures/{{ publication.illustrationId }}"/>
<div class="body">
<h1>{{publication.title}}</h1>
<h2>{{publication.description}}</h2>
</div>
<div class="footer">
<img src="/api/pictures/{{ publication.author.image }}" [matTooltip]="publication.author.name"/>
<span i18n>Publication posted by {{publication.author.name}}</span>
<span class="publication-date">
({{ publication.creationDate | date: 'short' }})
</span>
</div>
</a>
}
@@ -0,0 +1,87 @@
$cardBorderRadius: .5em;
:host {
display: flex;
flex-direction: column;
gap: 2em;
max-width: 50em;
width: 90%;
margin: auto;
.publication {
display: flex;
flex-direction: column;
border-radius: $cardBorderRadius;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
transition: box-shadow .2s ease-in-out;
text-decoration: none;
color: black;
background-color: #ffffff;
&:hover {
box-shadow: 0 4px 8px 0 rgba(0,0,0,.24),0 4px 14px 0 rgba(0,0,0,.16);
}
img {
object-fit: cover;
height: 15em;
border-radius: $cardBorderRadius $cardBorderRadius 0 0;
transition: height .2s ease-in-out;
@media screen and (min-width: 450px) {
height: 20em;
}
@media screen and (min-width: 600px) {
height: 25em;
}
@media screen and (min-width: 750px) {
height: 32em;
}
}
.body {
display: flex;
flex-direction: column;
padding: 1.5em 2em;
h1 {
font-size: 1.8em;
margin-bottom: .5em;
}
h2 {
font-size: 1em;
line-height: 1.4em;
margin: 0;
color: #747373;
font-weight: 400;
}
}
.footer {
display: flex;
flex-direction: row;
align-items: center;
background-color: #f0f0f0;
border-radius: 0 0 $cardBorderRadius $cardBorderRadius;
padding: 1em 2em;
gap: 1em;
color: #6c757d;
img {
$imageSize: 4em;
border-radius: 10em;
width: $imageSize;
height: $imageSize;
object-fit: cover;
}
.publication-date {
font-style: italic;
color: #bdbdbd;
}
}
}
}
@@ -0,0 +1,18 @@
import { Component, Input } from "@angular/core";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { Observable } from "rxjs";
import { CommonModule } from "@angular/common";
import { RouterModule } from "@angular/router";
import { MatTooltipModule } from "@angular/material/tooltip";
@Component({
selector: 'app-publication-list',
standalone: true,
templateUrl: './publication-list.component.html',
styleUrl: './publication-list.component.scss',
imports: [CommonModule, RouterModule, MatTooltipModule]
})
export class PublicationListComponent {
@Input()
publications$!: Observable<Publication[]>;
}
@@ -0,0 +1,6 @@
<form [formGroup]="formGroup">
<input name="search-query" placeholder="Search something..." formControlName="criteria" i18n-placeholder/>
<button type="submit" (click)="searchPublications()">
<mat-icon>search</mat-icon>
</button>
</form>
@@ -0,0 +1,37 @@
:host {
$borderRadiusValue: 10em;
position: relative;
flex-direction: row;
align-items: center;
form {
display: flex;
input {
flex: 1;
border-radius: $borderRadiusValue;
background-color: white;
border: solid 1px #ddd;
padding: .2em 2.7em .2em 1em;
height: 2em;
width: 100%;
}
button {
position: absolute;
display: flex;
align-items: center;
border-radius: $borderRadiusValue;
background-color: white;
border: none;
top: .4em;
right: 0;
color: #aaaaaa;
&:hover {
background-color: #eee;
cursor: pointer;
}
}
}
}
@@ -0,0 +1,36 @@
import { HttpParams } from "@angular/common/http";
import { Component, inject } from "@angular/core";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { MatIconModule } from "@angular/material/icon";
import { Router } from "@angular/router";
@Component({
selector: 'app-publications-search-bar',
templateUrl: './publications-search-bar.component.html',
styleUrl: './publications-search-bar.component.scss',
standalone: true,
imports: [
ReactiveFormsModule,
MatIconModule
],
providers: []
})
export class PublicationsSearchBarComponent {
private formBuilder = inject(FormBuilder);
private router = inject(Router);
formGroup = this.formBuilder.group({
criteria: new FormControl<string | undefined>('', [Validators.required])
});
searchPublications(): void {
const query = this.formGroup.controls.criteria.value
if (query?.trim()) {
const queryParams = { 'query' : this.formGroup.controls.criteria.value ?? '' }
this.router.navigate(['/publications'], { queryParams });
} else {
this.router.navigate(['/home']);
}
}
}
@@ -0,0 +1,15 @@
@for(category of categories(); track category) {
<div class="category {{category.isOpenned ? 'openned' : ''}}">
<div id="category-{{category.id}}" class="category-header" (click)="setOpenned(category)">
{{category.name}}
<mat-icon>chevron_right</mat-icon>
</div>
<div class="sub-category-container {{category.isOpenned ? 'displayed' : ''}}">
@for(subCategory of category.subCategories; track subCategory) {
<a [routerLink]="['/publications']" [queryParams]="{'category-id': subCategory.id}" (click)="categoryClicked.emit()" class="sub-category">
{{subCategory.name}}
</a>
}
</div>
</div>
}
@@ -0,0 +1,57 @@
:host {
display: flex;
flex-direction: column;
.category {
transition: background-color .2s ease-in-out;
&:hover {
cursor: pointer;
background-color: #5c6bc0;
}
.category-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: .5em 1em;
mat-icon {
transition: transform .2s ease-in-out;
}
}
&.openned {
.category-header {
mat-icon {
transform: rotate(90deg);
}
}
.sub-category-container {
max-height: none;
}
}
.sub-category-container {
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 0;
transition: max-height .2s ease-in-out;
background-color: #303f9f;
.sub-category {
padding: .5em 1em .5em 2em;
text-decoration: none;
color: inherit;
transition: background-color .2s ease-in-out;
&:hover {
background-color: #5c6bc0;
}
}
}
}
}
@@ -0,0 +1,63 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, inject, OnDestroy, OnInit, Output, signal } from "@angular/core";
import { MatIconModule } from "@angular/material/icon";
import { DisplayableCategory, SideMenuService } from "../side-menu.service";
import { Observable, Subscription } from "rxjs";
import { RouterModule } from "@angular/router";
import { Category } from "../../../core/rest-services/category/model/category";
@Component({
selector: 'app-categories-menu',
standalone: true,
imports: [CommonModule, RouterModule, MatIconModule],
templateUrl: './categories-menu.component.html',
styleUrl: './categories-menu.component.scss'
})
export class CategoriesMenuComponent implements OnInit, OnDestroy {
@Output()
categoryClicked = new EventEmitter<void>();
private sideMenuService = inject(SideMenuService);
private categoriesSubscription?: Subscription;
categories = signal<DisplayableCategory[]>([]);
ngOnInit(): void {
this.categoriesSubscription = this.sideMenuService.categories$
.subscribe(categories => this.categories.set(categories));
this.sideMenuService.loadCategories();
}
ngOnDestroy(): void {
this.categoriesSubscription?.unsubscribe();
}
setOpenned(category: DisplayableCategory): void {
if (category.isOpenned) {
const categoryDiv = document.getElementById(`category-${category.id}`);
if (categoryDiv) {
this.closeAccordion(categoryDiv);
}
} else {
const categoriesDivs = document.getElementsByClassName('category-header');
Array.from(categoriesDivs)
.map(category => category as HTMLElement)
.forEach(categoryDiv => this.closeAccordion(categoryDiv));
const categoryDiv = document.getElementById(`category-${category.id}`);
if (categoryDiv) {
this.openAccordion(categoryDiv);
}
}
this.sideMenuService.setOpenned(category);
}
private closeAccordion(categoryDiv: HTMLElement): void {
const divContent = categoryDiv?.nextElementSibling as HTMLElement;
divContent.style.maxHeight = '0';
}
private openAccordion(categoryDiv: HTMLElement): void {
const divContent = categoryDiv?.nextElementSibling as HTMLElement;
divContent.style.maxHeight = `${divContent.scrollHeight}px`;
}
}
@@ -0,0 +1,14 @@
<div class="menu {{ isOpenned() ? 'displayed' : '' }}">
<h1>
<a [routerLink]="['/home']">
<img src="assets/images/codiki.png" alt="logo"/>
Codiki
</a>
<button type="button" (click)="close()" matTooltip="Close the menu" i18n-matTooltip>
<mat-icon>close</mat-icon>
</button>
</h1>
<h2 i18n>Categories</h2>
<app-categories-menu (categoryClicked)="close()"></app-categories-menu>
</div>
<div class="overlay {{ isOpenned() ? 'displayed' : ''}}" (click)="close()"></div>
@@ -0,0 +1,86 @@
:host {
.menu {
display: flex;
flex-direction: column;
background-color: #3f51b5;
color: white;
$categoriesMenuWidth: 20em;
position: fixed;
top: 0;
left: -$categoriesMenuWidth - 1em - 1;
bottom: 0;
transition: left .2s ease-in-out;
width: $categoriesMenuWidth;
z-index: 3;
padding: 1em 0;
&.displayed {
left: 0;
}
h1 {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 1em;
a {
display: flex;
flex-direction: row;
justify-content: start;
align-items: center;
gap: .5em;
color: white;
text-decoration: none;
img {
$imageSize: 1.2em;
width: $imageSize;
height: $imageSize;
}
}
button {
border-radius: 10em;
border: none;
display: flex;
justify-content: center;
align-items: center;
width: 2.5em;
height: 2.5em;
color: white;
background-color: #3f51b5;
transition: background-color .2s ease-in-out;
&:hover {
cursor: pointer;
background-color: #5c6bc0;
}
}
}
h2 {
padding: 0 1em;
}
}
.overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: #000;
opacity: .2;
z-index: 2;
&.displayed {
display: block;
}
}
}
@@ -0,0 +1,24 @@
import { Component, signal } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { CategoriesMenuComponent } from './categories-menu/categories-menu.component';
@Component({
selector: 'app-side-menu',
standalone: true,
imports: [CategoriesMenuComponent, MatIconModule, MatTooltipModule, RouterModule],
templateUrl: './side-menu.component.html',
styleUrl: './side-menu.component.scss'
})
export class SideMenuComponent {
isOpenned = signal(false);
open(): void {
this.isOpenned.set(true);
}
close(): void {
this.isOpenned.set(false);
}
}
@@ -0,0 +1,97 @@
import { Injectable, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { BehaviorSubject, Observable } from 'rxjs';
import { CategoryRestService } from '../../core/rest-services/category/category.rest-service';
import { Category } from '../../core/rest-services/category/model/category';
export interface DisplayableCategory {
id: string;
name: string;
subCategories: DisplayableSubCategory[];
isOpenned: boolean;
}
export interface DisplayableSubCategory {
id: string;
name: string;
}
@Injectable({
providedIn: 'root'
})
export class SideMenuService {
private categoryRestService = inject(CategoryRestService);
private snackBar = inject(MatSnackBar);
private categoriesSubject = new BehaviorSubject<DisplayableCategory[]>([]);
private isLoadingSubject = new BehaviorSubject<boolean>(false);
private isLoadedSubject = new BehaviorSubject<boolean>(false);
private mapToDisplayableCategory(category: Category): DisplayableCategory {
return {
id: category.id,
name: category.name,
subCategories: category.subCategories.map(subCategory => this.mapToDisplayableSubCategory(subCategory)),
isOpenned: false
};
}
private mapToDisplayableSubCategory(subCategory: Category): DisplayableSubCategory {
return {
id: subCategory.id,
name: subCategory.name
}
}
private get categories(): DisplayableCategory[] {
return this.categoriesSubject.value;
}
private save(categories: DisplayableCategory[]): void {
this.categoriesSubject.next(categories);
}
get categories$(): Observable<DisplayableCategory[]> {
return this.categoriesSubject.asObservable();
}
get isLoading$(): Observable<boolean> {
return this.isLoadingSubject.asObservable();
}
loadCategories(): void {
this.isLoadingSubject.next(true);
this.isLoadedSubject.next(false);
this.categoryRestService.getCategories()
.then(categories => {
const displayableCategories = categories
.filter(category => category.subCategories?.length)
.map(category => this.mapToDisplayableCategory(category));
this.categoriesSubject.next(displayableCategories);
})
.catch(error => {
const errorMessage = $localize`An error occured while loading categories.`;
console.error(errorMessage, error);
this.snackBar.open(errorMessage, $localize`Close`, { duration: 5000 });
})
.finally(() => {
this.isLoadingSubject.next(false);
this.isLoadedSubject.next(true);
});
}
setOpenned(category: DisplayableCategory): void {
const categories = this.categories;
const matchingCategory = categories.find(categoryTemp => categoryTemp.id === category.id);
if (matchingCategory) {
const actualOpennedCategory = categories.find(category => category.isOpenned);
if (actualOpennedCategory && actualOpennedCategory.id === matchingCategory.id) {
matchingCategory.isOpenned = false;
} else {
categories.forEach(categoryTemp => categoryTemp.isOpenned = false);
matchingCategory.isOpenned = true;
}
this.save(categories);
}
}
}
@@ -0,0 +1,8 @@
<button type="submit" [class]="color" [disabled]="disabled || requestPending" (click)="click.emit()">
@if (requestPending) {
<mat-spinner class="spinner {{color}}" [diameter]="25"></mat-spinner>
}
<span>
{{ label }}
</span>
</button>
@@ -0,0 +1,43 @@
button {
padding: .8em 1.2em;
border-radius: 10em;
border: none;
background-color: #3f51b5;
color: white;
transition: background-color .2s ease-in-out;
position: relative;
&:hover {
background-color: #5b6ed8;
cursor: pointer;
}
&.secondary {
color: #3f51b5;
background-color: white;
&:hover {
background-color: #f2f4ff;
cursor: pointer;
}
}
&:disabled {
background-color: #6d7ac5;
cursor: not-allowed;
}
mat-spinner {
position: absolute;
top: calc(50% - 12px);
left: calc(50% - 12px);
}
}
:host ::ng-deep .spinner circle {
stroke: white;
}
:host ::ng-deep .spinner.secondary circle {
stroke: #3f51b5;
}

Some files were not shown because too many files have changed in this diff Show More