mirror of
https://github.com/Floriansylvain/RenewCMS.git
synced 2026-08-19 11:43:22 +02:00
Merge pull request #20 from Floriansylvain/dev
Multiple refactors and routes improvement
This commit is contained in:
@@ -1,23 +1,43 @@
|
||||
# GohCMS
|
||||
|
||||
## 🚧 This project is under development !
|
||||
|
||||
The features are not complete, not fully tested and subject to many changes.
|
||||
|
||||
## Setup
|
||||
|
||||
Don't forget to setup the [Environment variables](#environment-variables)!
|
||||
|
||||
### Use with Docker
|
||||
|
||||
To **run** the app, run the command
|
||||
|
||||
```shell
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
### Build and run it yourself
|
||||
|
||||
TODO
|
||||
## Environment variables
|
||||
- ./env
|
||||
- APP_BASE_API_PATH
|
||||
- APP_BASE_FRONT_PATH
|
||||
- APP_API_ADDRESS
|
||||
- APP_FRONT_ADDRESS
|
||||
- APP_API_PORT
|
||||
- APP_FRONT_PORT
|
||||
- APP_JWT_SECRET
|
||||
|
||||
### Environment variables
|
||||
|
||||
- ./env:
|
||||
|
||||
```
|
||||
APP_API_ADDRESS=http://example.com
|
||||
APP_FRONT_ADDRESS=http://example.com
|
||||
APP_BASE_API_PATH=/example
|
||||
APP_BASE_FRONT_PATH=/example
|
||||
APP_API_PORT=1234
|
||||
APP_FRONT_PORT=1234
|
||||
APP_JWT_SECRET=secret
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
TODO
|
||||
|
||||
## Demo
|
||||
|
||||
TODO
|
||||
|
||||
+5
-5
@@ -63,11 +63,11 @@ func initArticlesRoutes(r *gin.RouterGroup) {
|
||||
articlesRouter := r.Group("/articles")
|
||||
articlesRouter.Use(corsMiddleware, api.AuthMiddleware.MiddlewareFunc())
|
||||
|
||||
articlesRouter.GET("/", articles.GetArticleHandler)
|
||||
articlesRouter.GET("/:id", articles.GetArticleHandler)
|
||||
articlesRouter.POST("/:id", articles.AddArticleHandler)
|
||||
articlesRouter.PATCH("/:id", articles.EditArticleHandler)
|
||||
articlesRouter.DELETE("/:id", articles.DeleteArticleHandler)
|
||||
articlesRouter.GET("/", articles.Get)
|
||||
articlesRouter.GET("/:id", articles.GetUnique)
|
||||
articlesRouter.POST("/:id", articles.Add)
|
||||
articlesRouter.PUT("/:id", articles.Edit)
|
||||
articlesRouter.DELETE("/:id", articles.Delete)
|
||||
}
|
||||
|
||||
func initGin() {
|
||||
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
)
|
||||
|
||||
func SendBadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": message})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": message, "code": 400})
|
||||
}
|
||||
|
||||
func SendOk(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": message})
|
||||
c.JSON(http.StatusOK, gin.H{"message": message, "code": 200})
|
||||
}
|
||||
|
||||
func SendForbidden(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": message})
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": message, "code": 403})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Floriansylvain/GohCMS/internal/api"
|
||||
"github.com/Floriansylvain/GohCMS/internal/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
func Add(c *gin.Context) {
|
||||
var article Article
|
||||
article.TitleID = c.Params.ByName("id")
|
||||
if c.BindJSON(&article) != nil {
|
||||
api.SendBadRequest(c, "Could not correctly parse the article.")
|
||||
return
|
||||
}
|
||||
|
||||
document, err := bson.Marshal(article)
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, "Could not correctly marshal the article.")
|
||||
return
|
||||
}
|
||||
|
||||
documents, _ := database.GetDocuments(articlesLocation, map[string]any{})
|
||||
if IsArticleIdAlreadyUsed(article.TitleID, documents) {
|
||||
api.SendBadRequest(c, fmt.Sprintf("Article ID '%v' already used.", article.TitleID))
|
||||
return
|
||||
}
|
||||
|
||||
err = database.PushDocument(articlesLocation, document)
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, fmt.Sprintf(`Could not insert document(s) into DB: %v`, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
api.SendOk(c, "Article successfully added!")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Floriansylvain/GohCMS/internal/api"
|
||||
"github.com/Floriansylvain/GohCMS/internal/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Delete(c *gin.Context) {
|
||||
id := c.Params.ByName("id")
|
||||
|
||||
deleteCount, err := database.DeleteDocument(articlesLocation, map[string]any{"titleID": id})
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, fmt.Sprintf(`Could not delete document(s) from DB: %v`, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if deleteCount != 0 {
|
||||
api.SendOk(c, fmt.Sprintf("%d article(s) was/were successfully deleted!", deleteCount))
|
||||
} else {
|
||||
api.SendOk(c, "No articles were deleted.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Floriansylvain/GohCMS/internal/api"
|
||||
"github.com/Floriansylvain/GohCMS/internal/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Edit(c *gin.Context) {
|
||||
id := c.Params.ByName("id")
|
||||
|
||||
var articleUpdate database.DocumentUpdate
|
||||
articleUpdate.Filter = map[string]any{"titleID": id}
|
||||
c.BindJSON(&articleUpdate.Update)
|
||||
|
||||
editCount, err := database.EditDocument(articlesLocation, articleUpdate)
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, fmt.Sprintf(`Could not edit document(s) from DB: %v`, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if editCount != 0 {
|
||||
api.SendOk(c, fmt.Sprintf("%d article(s) was/were successfully edited!", editCount))
|
||||
} else {
|
||||
api.SendOk(c, "No articles were edited.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/Floriansylvain/GohCMS/internal/api"
|
||||
"github.com/Floriansylvain/GohCMS/internal/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
func getApiFullUrl() string {
|
||||
return fmt.Sprintf("%v%v", os.Getenv("APP_API_ADDRESS"), os.Getenv("APP_BASE_API_PATH"))
|
||||
}
|
||||
|
||||
func getArticleSkipTakeFullUrl(skip uint64, take uint64) string {
|
||||
return fmt.Sprintf("%varticles?skip=%v&take=%v", getApiFullUrl(), skip+take, take)
|
||||
}
|
||||
|
||||
func getBuiltGetResponse(articles []Article, skip uint64, take uint64) map[string]any {
|
||||
articlesCap := uint64(len(articles))
|
||||
|
||||
var normTake uint64
|
||||
if skip+take > articlesCap {
|
||||
normTake = articlesCap
|
||||
} else {
|
||||
normTake = skip + take
|
||||
}
|
||||
|
||||
slicedArticles := articles[skip:normTake]
|
||||
total := len(slicedArticles)
|
||||
return map[string]any{
|
||||
"content": slicedArticles,
|
||||
"total": total,
|
||||
"pagination": map[string]any{
|
||||
"skip": skip,
|
||||
"take": take,
|
||||
"links": map[string]any{
|
||||
"next": getArticleSkipTakeFullUrl(skip+take, take),
|
||||
"previous": getArticleSkipTakeFullUrl(skip-take, take),
|
||||
},
|
||||
},
|
||||
"last_page": math.Ceil(float64(articlesCap) / float64(take)),
|
||||
}
|
||||
}
|
||||
|
||||
func parseUintQueryParam(c *gin.Context, param string, defaultValue uint64) (uint64, error) {
|
||||
if c.Query(param) == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
value, err := strconv.ParseUint(c.Query(param), 10, 0)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func getArticleIdFilter(titleID string) map[string]any {
|
||||
filter := map[string]any{}
|
||||
if titleID != "" {
|
||||
filter["titleID"] = titleID
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func Get(c *gin.Context) {
|
||||
skip, skipErr := parseUintQueryParam(c, "skip", 0)
|
||||
take, takeErr := parseUintQueryParam(c, "take", 10)
|
||||
if skipErr != nil || takeErr != nil {
|
||||
api.SendBadRequest(c, "Take and Skip query parameters must be positive numbers.")
|
||||
return
|
||||
}
|
||||
|
||||
documents, _ := database.GetDocuments(articlesLocation, map[string]any{})
|
||||
articlesArray := ParseArticlesFromBytesToArray(documents)
|
||||
articlesArrayLength := uint64(len(articlesArray))
|
||||
if skip >= articlesArrayLength || take == 0 {
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"content": []string{},
|
||||
"total": "0",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, getBuiltGetResponse(articlesArray, skip, take))
|
||||
}
|
||||
|
||||
func GetUnique(c *gin.Context) {
|
||||
titleID := c.Params.ByName("id")
|
||||
|
||||
article, err := database.GetUniqueDocument(articlesLocation, getArticleIdFilter(titleID))
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, fmt.Sprintf("The ID '%v' doesn't match any article.", titleID))
|
||||
return
|
||||
}
|
||||
|
||||
var parsedArticle Article
|
||||
bson.Unmarshal(article, &parsedArticle)
|
||||
|
||||
c.JSON(http.StatusOK, parsedArticle)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Floriansylvain/GohCMS/internal/api"
|
||||
"github.com/Floriansylvain/GohCMS/internal/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
func GetArticleHandler(c *gin.Context) {
|
||||
articleID := c.Params.ByName("id")
|
||||
|
||||
filter := gin.H{}
|
||||
if articleID != "" {
|
||||
filter["titleID"] = articleID
|
||||
}
|
||||
|
||||
articles, err := database.GetDocuments(articlesLocation, filter)
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, fmt.Sprintf("The ID '%v' doesn't match any article.", articleID))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ParseArticlesFromBytesToArray(articles))
|
||||
}
|
||||
|
||||
func AddArticleHandler(c *gin.Context) {
|
||||
var article Article
|
||||
article.TitleID = c.Params.ByName("id")
|
||||
if c.BindJSON(&article) != nil {
|
||||
api.SendBadRequest(c, "Could not correctly parse the article.")
|
||||
return
|
||||
}
|
||||
|
||||
document, err := bson.Marshal(article)
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, "Could not correctly marshal the article.")
|
||||
return
|
||||
}
|
||||
|
||||
documents, _ := database.GetDocuments(articlesLocation, gin.H{})
|
||||
if IsArticleIdAlreadyUsed(article.TitleID, documents) {
|
||||
api.SendBadRequest(c, fmt.Sprintf("Article ID '%v' already used.", article.TitleID))
|
||||
return
|
||||
}
|
||||
|
||||
err = database.PushDocument(articlesLocation, document)
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, fmt.Sprintf(`Could not insert document(s) into DB: %v`, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
api.SendOk(c, "Article successfully added!")
|
||||
}
|
||||
|
||||
func DeleteArticleHandler(c *gin.Context) {
|
||||
id := c.Params.ByName("id")
|
||||
|
||||
deleteCount, err := database.DeleteDocument(articlesLocation, gin.H{"titleID": id})
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, fmt.Sprintf(`Could not delete document(s) from DB: %v`, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if deleteCount != 0 {
|
||||
api.SendOk(c, fmt.Sprintf("%d article(s) was/were successfully deleted!", deleteCount))
|
||||
} else {
|
||||
api.SendOk(c, "No articles were deleted.")
|
||||
}
|
||||
}
|
||||
|
||||
func EditArticleHandler(c *gin.Context) {
|
||||
id := c.Params.ByName("id")
|
||||
|
||||
var articleUpdate database.DocumentUpdate
|
||||
articleUpdate.Filter = gin.H{"titleID": id}
|
||||
c.BindJSON(&articleUpdate.Update)
|
||||
|
||||
editCount, err := database.EditDocument(articlesLocation, articleUpdate)
|
||||
if err != nil {
|
||||
api.SendBadRequest(c, fmt.Sprintf(`Could not edit document(s) from DB: %v`, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if editCount != 0 {
|
||||
api.SendOk(c, fmt.Sprintf("%d article(s) was/were successfully edited!", editCount))
|
||||
} else {
|
||||
api.SendOk(c, "No articles were edited.")
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
@@ -14,11 +12,6 @@ type Location struct {
|
||||
Collection string
|
||||
}
|
||||
|
||||
type DocumentUpdate struct {
|
||||
Filter gin.H `json:"filter"`
|
||||
Update gin.H `json:"update"`
|
||||
}
|
||||
|
||||
func GetNewClient() *mongo.Client {
|
||||
client, err := mongo.Connect(
|
||||
context.TODO(),
|
||||
@@ -28,73 +21,3 @@ func GetNewClient() *mongo.Client {
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func PushDocument(location Location, document interface{}) error {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
_, err := collection.InsertOne(context.TODO(), document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetDocuments(location Location, filter interface{}) ([][]byte, error) {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
var results [][]byte
|
||||
|
||||
cursor, err := collection.Find(context.TODO(), filter)
|
||||
if err != nil {
|
||||
return results, errors.New("something is wrong with filter to find the document.")
|
||||
}
|
||||
for cursor.TryNext(context.TODO()) {
|
||||
results = append(results, cursor.Current)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func GetUniqueDocument(location Location, filter interface{}) ([]byte, error) {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
singleResult := collection.FindOne(context.TODO(), filter)
|
||||
if singleResult.Err() != nil {
|
||||
return nil, errors.New("no document was found, check the filter or the location.")
|
||||
}
|
||||
return singleResult.DecodeBytes()
|
||||
}
|
||||
|
||||
func DeleteDocument(location Location, filter interface{}) (int64, error) {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
result, err := collection.DeleteOne(context.TODO(), filter)
|
||||
if err != nil {
|
||||
return 0, errors.New("something is wrong with filter to delete the document.")
|
||||
}
|
||||
|
||||
return result.DeletedCount, nil
|
||||
}
|
||||
|
||||
func EditDocument(location Location, jsons DocumentUpdate) (int64, error) {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
result, err := collection.UpdateOne(context.TODO(), jsons.Filter, gin.H{"$set": jsons.Update})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if result.MatchedCount == 0 {
|
||||
return 0, errors.New("cannot find document matching filter.")
|
||||
}
|
||||
|
||||
return result.ModifiedCount, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func DeleteDocument(location Location, filter interface{}) (int64, error) {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
result, err := collection.DeleteOne(context.TODO(), filter)
|
||||
if err != nil {
|
||||
return 0, errors.New("something is wrong with filter to delete the document.")
|
||||
}
|
||||
|
||||
return result.DeletedCount, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DocumentUpdate struct {
|
||||
Filter gin.H `json:"filter"`
|
||||
Update gin.H `json:"update"`
|
||||
}
|
||||
|
||||
func EditDocument(location Location, jsons DocumentUpdate) (int64, error) {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
result, err := collection.UpdateOne(context.TODO(), jsons.Filter, gin.H{"$set": jsons.Update})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if result.MatchedCount == 0 {
|
||||
return 0, errors.New("cannot find document matching filter.")
|
||||
}
|
||||
|
||||
return result.ModifiedCount, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func GetDocuments(location Location, filter interface{}) ([][]byte, error) {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
var results [][]byte
|
||||
|
||||
cursor, err := collection.Find(context.TODO(), filter)
|
||||
if err != nil {
|
||||
return results, errors.New("something is wrong with filter to find the document.")
|
||||
}
|
||||
for cursor.TryNext(context.TODO()) {
|
||||
results = append(results, cursor.Current)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func GetUniqueDocument(location Location, filter interface{}) ([]byte, error) {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
singleResult := collection.FindOne(context.TODO(), filter)
|
||||
if singleResult.Err() != nil {
|
||||
return nil, errors.New("no document was found, check the filter or the location.")
|
||||
}
|
||||
return singleResult.DecodeBytes()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package database
|
||||
|
||||
import "context"
|
||||
|
||||
func PushDocument(location Location, document interface{}) error {
|
||||
client := GetNewClient()
|
||||
collection := client.Database(location.Database).Collection(location.Collection)
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
_, err := collection.InsertOne(context.TODO(), document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -103,6 +103,12 @@ th,
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.label-input input:focus,
|
||||
.label-input-error input:focus {
|
||||
border-color: var(--primary);
|
||||
outline: solid 3px var(--primary-light);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -181,6 +187,11 @@ th,
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.button-primary:focus,
|
||||
.button-secondary:focus {
|
||||
outline: solid 3px var(--primary-light);
|
||||
}
|
||||
|
||||
.tox-tinymce {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
|
||||
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"fr-FR": {
|
||||
"pagination": {
|
||||
"first": "Premier",
|
||||
"first_title": "Première Page",
|
||||
"last": "Dernier",
|
||||
"last_title": "Dernière Page",
|
||||
"prev": "Précédent",
|
||||
"prev_title": "Page Précédente",
|
||||
"next": "Suivant",
|
||||
"next_title": "Page Suivante",
|
||||
"all": "Toute",
|
||||
"page_size": "Nombre d'éléments"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
const emits = defineEmits(['close'])
|
||||
const props = defineProps<{
|
||||
title?: string,
|
||||
description: string,
|
||||
type: 'error' | 'success'
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal">
|
||||
<header class="error-bg" v-if="type === 'error'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<h2>{{ props.title ?? 'Échec !' }}</h2>
|
||||
</header>
|
||||
<header class="success-bg" v-else>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h2>{{ props.title ?? 'Succès !' }}</h2>
|
||||
</header>
|
||||
<main>
|
||||
<p>{{ props.description }}</p>
|
||||
<button class="button-primary" @click="$emit('close')">Fermer</button>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
|
||||
box-shadow: #0002 0 0 15px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
|
||||
max-width: 100%;
|
||||
width: 500px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.success-bg {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.error-bg {
|
||||
background-color: var(--secondary-light);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: var(--neutral-dark);
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
main p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 50px;
|
||||
color: var(--neutral-dark);
|
||||
}
|
||||
</style>
|
||||
@@ -78,6 +78,10 @@ header nav a:hover {
|
||||
background-color: var(--neutral-verylight);
|
||||
}
|
||||
|
||||
header nav a:focus {
|
||||
outline: solid 3px var(--primary-light);
|
||||
}
|
||||
|
||||
.disconnect-link {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -11,25 +11,61 @@ export interface Article {
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export async function getArticles(id: string) : Promise<Array<Article>> {
|
||||
return await fetch(`${baseApiUrl}/articles/${id}`, {
|
||||
credentials: 'include',
|
||||
method: 'GET',
|
||||
})
|
||||
.then(result => result.json())
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
})
|
||||
export interface GetArticle {
|
||||
content: Article[],
|
||||
total: number
|
||||
pagination: {
|
||||
skip: number,
|
||||
take: number
|
||||
links: {
|
||||
next: string,
|
||||
previous: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function postArticle(article: Article) : Promise<object> {
|
||||
return await fetch(`${baseApiUrl}/articles/${article.titleID}`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
body: JSON.stringify(article)
|
||||
})
|
||||
.then(result => result.json())
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
function fetchArticle(id: string): Promise<Article | GetArticle> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`${baseApiUrl}/articles/${id}`, {
|
||||
credentials: 'include',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
.then(response => response.json())
|
||||
.then(article => resolve(article))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUniqueArticle(id: string): Promise<Article> {
|
||||
return fetchArticle(id) as Promise<Article>
|
||||
|
||||
}
|
||||
|
||||
export function fetchAllArticle(): Promise<GetArticle> {
|
||||
return fetchArticle("") as Promise<GetArticle>
|
||||
}
|
||||
|
||||
export function deleteArticle(id: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`${baseApiUrl}/articles/${id}`, {
|
||||
credentials: 'include',
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(result => result.json())
|
||||
.then(article => resolve(article))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
export function sendArticleWithMethod(article: Article, method: 'POST' | 'PUT'): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`${baseApiUrl}/articles/${article.titleID}`, {
|
||||
credentials: 'include',
|
||||
method,
|
||||
body: JSON.stringify(article)
|
||||
})
|
||||
.then(result => result.json())
|
||||
.then(article => resolve(article))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
export function reloadPage() {
|
||||
router.go(0)
|
||||
}
|
||||
@@ -1,20 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { getArticles, type Article } from '@/utils/database';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import tabulator_langs from '@/assets/tabulator_langs.json'
|
||||
import { baseApiUrl } from '@/utils/api'
|
||||
import { deleteArticle } from '@/utils/database'
|
||||
import { reloadPage } from '@/utils/router'
|
||||
import { TabulatorFull as Tabulator, type CellComponent } from 'tabulator-tables'
|
||||
import { onMounted, ref, type Ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { TabulatorFull as Tabulator } from 'tabulator-tables'
|
||||
|
||||
const articles: Ref<Array<Article>> = ref([])
|
||||
interface BasicTableButton {
|
||||
classes: string[],
|
||||
text: string
|
||||
}
|
||||
|
||||
const table = ref<HTMLInputElement | string>('')
|
||||
const table: Ref<HTMLInputElement | string> = ref('')
|
||||
const tabulator: Ref<Tabulator | undefined> = ref(undefined)
|
||||
|
||||
onMounted(async () => {
|
||||
articles.value = await getArticles('')
|
||||
const basicEditButton = getBasicTableButton({ classes: ['button-secondary', 'table-action-button'], text: '✏️' })
|
||||
const basicDeleteButton = getBasicTableButton({ classes: ['button-secondary', 'table-action-button'], text: '❌' })
|
||||
|
||||
function getBasicTableButton(basicButton: BasicTableButton): HTMLAnchorElement {
|
||||
const button = document.createElement('a')
|
||||
button.classList.add(...basicButton.classes)
|
||||
button.textContent = basicButton.text
|
||||
return button
|
||||
}
|
||||
|
||||
function updateTablePage(): void {
|
||||
const currentPage = tabulator.value?.getPage()
|
||||
|
||||
if (isNaN(currentPage as number)) {
|
||||
reloadPage()
|
||||
} else {
|
||||
tabulator.value?.setPage(currentPage as number)
|
||||
}
|
||||
}
|
||||
|
||||
function getRowEditButton(cell: CellComponent): Node {
|
||||
const editButton = basicEditButton.cloneNode(true) as HTMLAnchorElement
|
||||
editButton.href = `/articles/edit/${cell.getValue()}`
|
||||
return editButton
|
||||
}
|
||||
|
||||
function getRowDeleteButton(cell: CellComponent): Node {
|
||||
const deleteButton = basicDeleteButton.cloneNode(true) as HTMLAnchorElement
|
||||
|
||||
deleteButton.onclick = async () => {
|
||||
await deleteArticle(cell.getValue())
|
||||
updateTablePage()
|
||||
}
|
||||
|
||||
return deleteButton
|
||||
}
|
||||
|
||||
function getButtonsCell(cell: CellComponent): HTMLDivElement {
|
||||
const container = document.createElement('div')
|
||||
container.append(getRowEditButton(cell), getRowDeleteButton(cell))
|
||||
return container
|
||||
}
|
||||
|
||||
function getTableAjaxUrlPage(url: string, config: any, params: any) {
|
||||
config.credentials = 'include'
|
||||
return url + `?skip=${params.size * (params.page - 1)}&take=${params.size}`
|
||||
}
|
||||
|
||||
function initTabulatorTable(): void {
|
||||
tabulator.value = new Tabulator(table.value, {
|
||||
data: articles.value,
|
||||
reactiveData: true,
|
||||
layout: 'fitColumns',
|
||||
locale: 'fr-FR',
|
||||
reactiveData: true,
|
||||
selectable: false,
|
||||
pagination: true,
|
||||
paginationSizeSelector: true,
|
||||
paginationMode: 'remote',
|
||||
paginationSize: 10,
|
||||
ajaxURL: `${baseApiUrl}/articles`,
|
||||
ajaxURLGenerator: getTableAjaxUrlPage,
|
||||
dataReceiveParams: { data: "content" },
|
||||
columns: [
|
||||
{
|
||||
title: 'Titre',
|
||||
@@ -46,25 +107,16 @@ onMounted(async () => {
|
||||
{
|
||||
title: 'Actions',
|
||||
field: 'titleID',
|
||||
formatter: function (cell) {
|
||||
const container = document.createElement('div')
|
||||
|
||||
const editButton = document.createElement('a')
|
||||
const deleteButton = document.createElement('a')
|
||||
editButton.classList.add('button-secondary')
|
||||
deleteButton.classList.add('button-secondary')
|
||||
editButton.textContent = '✏️'
|
||||
deleteButton.textContent = '🗑️'
|
||||
|
||||
editButton.href = `/articles/edit/${cell.getValue()}`
|
||||
|
||||
container.append(editButton, deleteButton)
|
||||
return container
|
||||
},
|
||||
formatter: getButtonsCell,
|
||||
headerSort: false
|
||||
}
|
||||
]
|
||||
],
|
||||
langs: tabulator_langs
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initTabulatorTable()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -75,12 +127,20 @@ onMounted(async () => {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.table-action-button {
|
||||
margin: 0 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
height: 100%;
|
||||
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
@@ -90,19 +150,10 @@ main>a {
|
||||
|
||||
#table {
|
||||
width: 100%;
|
||||
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin: auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.action-buttons>* {
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { getArticles, type Article } from '@/utils/database';
|
||||
import Modal from '@/components/ModalSuccessError.vue';
|
||||
import { fetchUniqueArticle, sendArticleWithMethod, type Article } from '@/utils/database';
|
||||
import Editor from '@tinymce/tinymce-vue';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const article: Ref<Article | void> = ref()
|
||||
const editorData: Ref<string> = ref('')
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const successModalShow = ref(false)
|
||||
const errorModalShow = ref(false)
|
||||
const errorModalDescription: Ref<string> = ref("Quelque chose s'est mal passé...")
|
||||
|
||||
function displayErrorMessage(message: string): void {
|
||||
errorModalDescription.value = message
|
||||
errorModalShow.value = true
|
||||
}
|
||||
|
||||
async function getArticle(): Promise<Article | undefined> {
|
||||
try {
|
||||
return await fetchUniqueArticle(route.params.articleID as string)
|
||||
} catch {
|
||||
displayErrorMessage("Impossible de récupérer l'article. Vérifiez l'URL. Tentez-vous d'accéder au mode édition directement depuis un lien ?")
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const articleFetch = await getArticles(useRoute().params.articleID as string)
|
||||
article.value = articleFetch[0]
|
||||
editorData.value = article.value.content.html
|
||||
article.value = await getArticle()
|
||||
editorData.value = article.value?.content.html ?? ""
|
||||
})
|
||||
|
||||
function abort() {
|
||||
router.push('/articles')
|
||||
}
|
||||
|
||||
function saveContent() {
|
||||
console.log(editorData.value)
|
||||
if (article.value == undefined) return;
|
||||
|
||||
article.value.content.html = editorData.value
|
||||
|
||||
sendArticleWithMethod(article.value, 'PUT')
|
||||
.then(() => successModalShow.value = true)
|
||||
.catch(error => displayErrorMessage(`Impossible de sauvegarder l'article. (${error.toString()})`))
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div id="editor">
|
||||
@@ -33,9 +60,14 @@ function saveContent() {
|
||||
</div>
|
||||
<aside class="buttons">
|
||||
<button @click="saveContent()" class="button-primary">Enregistrer</button>
|
||||
<button @click="abort()" class="button-secondary">Annuler</button>
|
||||
<button @click="abort()" class="button-secondary">Retour</button>
|
||||
</aside>
|
||||
</div>
|
||||
<Modal v-if="errorModalShow" :description="errorModalDescription" @close="errorModalShow = false" type="error">
|
||||
</Modal>
|
||||
<Modal v-if="successModalShow" description="Le contenu a bien été sauvegardé." @close="successModalShow = false"
|
||||
type="success">
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { postArticle, type Article } from '@/utils/database';
|
||||
import { sendArticleWithMethod, type Article } from '@/utils/database';
|
||||
import { ref, type Ref } from 'vue';
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -29,7 +29,7 @@ function isFormEmpty(): boolean {
|
||||
return title.value === '' || rawTags.value === ''
|
||||
}
|
||||
|
||||
function createArticle() {
|
||||
async function createArticle(): Promise<Article> {
|
||||
const article: Article = {
|
||||
titleID: generateTitleID(title.value),
|
||||
title: title.value,
|
||||
@@ -40,7 +40,12 @@ function createArticle() {
|
||||
online: false,
|
||||
tags: tags.value
|
||||
}
|
||||
postArticle(article)
|
||||
await sendArticleWithMethod(article, 'POST')
|
||||
return article
|
||||
}
|
||||
|
||||
async function formSubmitHandler(): Promise<void> {
|
||||
const article = await createArticle()
|
||||
router.push(`/articles/edit/${article.titleID}`)
|
||||
}
|
||||
</script>
|
||||
@@ -48,7 +53,7 @@ function createArticle() {
|
||||
<template>
|
||||
<main>
|
||||
<h2>Créer un nouvel article</h2>
|
||||
<form @submit.prevent="createArticle()">
|
||||
<form @submit.prevent="formSubmitHandler()">
|
||||
<div class="inputs-group">
|
||||
<div class="label-input">
|
||||
<label for="title">Titre de l'article</label>
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p>home page</p>
|
||||
<main>
|
||||
<h1>Bienvenue.</h1>
|
||||
<h2>Vous êtes sur la page d'accueil de GohCMS.</h2>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
padding: 64px;
|
||||
color: var(--neutral-light);
|
||||
}
|
||||
|
||||
main h1 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
import { fileURLToPath, URL } from "node:url"
|
||||
import { defineConfig, loadEnv } from "vite"
|
||||
import { defineConfig, loadEnv, type UserConfig } from "vite"
|
||||
import vue from "@vitejs/plugin-vue"
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
export default defineConfig(({ command, mode }): UserConfig => {
|
||||
const env1 = loadEnv(mode, "./../../", "")
|
||||
const env2 = loadEnv(mode, process.cwd(), "")
|
||||
|
||||
@@ -21,7 +21,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: env1.APP_FRONT_PORT,
|
||||
port: parseInt(env1.APP_FRONT_PORT),
|
||||
},
|
||||
base: env1.APP_BASE_FRONT_PATH ?? "/"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user