Merge pull request #20 from Floriansylvain/dev

Multiple refactors and routes improvement
This commit is contained in:
Florian Sylvain
2023-02-07 21:39:00 +01:00
committed by GitHub
26 changed files with 661 additions and 261 deletions
+29 -9
View File
@@ -1,23 +1,43 @@
# GohCMS # GohCMS
## 🚧 This project is under development ! ## 🚧 This project is under development !
The features are not complete, not fully tested and subject to many changes. The features are not complete, not fully tested and subject to many changes.
## Setup ## Setup
Don't forget to setup the [Environment variables](#environment-variables)!
### Use with Docker ### Use with Docker
To **run** the app, run the command To **run** the app, run the command
```shell ```shell
docker-compose up docker-compose up
``` ```
### Build and run it yourself ### Build and run it yourself
TODO TODO
## Environment variables
- ./env ### Environment variables
- APP_BASE_API_PATH
- APP_BASE_FRONT_PATH - ./env:
- APP_API_ADDRESS
- APP_FRONT_ADDRESS ```
- APP_API_PORT APP_API_ADDRESS=http://example.com
- APP_FRONT_PORT APP_FRONT_ADDRESS=http://example.com
- APP_JWT_SECRET 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 ## Demo
TODO TODO
+5 -5
View File
@@ -63,11 +63,11 @@ func initArticlesRoutes(r *gin.RouterGroup) {
articlesRouter := r.Group("/articles") articlesRouter := r.Group("/articles")
articlesRouter.Use(corsMiddleware, api.AuthMiddleware.MiddlewareFunc()) articlesRouter.Use(corsMiddleware, api.AuthMiddleware.MiddlewareFunc())
articlesRouter.GET("/", articles.GetArticleHandler) articlesRouter.GET("/", articles.Get)
articlesRouter.GET("/:id", articles.GetArticleHandler) articlesRouter.GET("/:id", articles.GetUnique)
articlesRouter.POST("/:id", articles.AddArticleHandler) articlesRouter.POST("/:id", articles.Add)
articlesRouter.PATCH("/:id", articles.EditArticleHandler) articlesRouter.PUT("/:id", articles.Edit)
articlesRouter.DELETE("/:id", articles.DeleteArticleHandler) articlesRouter.DELETE("/:id", articles.Delete)
} }
func initGin() { func initGin() {
+3 -3
View File
@@ -7,13 +7,13 @@ import (
) )
func SendBadRequest(c *gin.Context, message string) { 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) { 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) { 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})
} }
+39
View File
@@ -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!")
}
+25
View File
@@ -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.")
}
}
+29
View File
@@ -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.")
}
}
+102
View File
@@ -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)
}
-93
View File
@@ -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.")
}
}
-77
View File
@@ -2,9 +2,7 @@ package database
import ( import (
"context" "context"
"errors"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/options"
) )
@@ -14,11 +12,6 @@ type Location struct {
Collection string Collection string
} }
type DocumentUpdate struct {
Filter gin.H `json:"filter"`
Update gin.H `json:"update"`
}
func GetNewClient() *mongo.Client { func GetNewClient() *mongo.Client {
client, err := mongo.Connect( client, err := mongo.Connect(
context.TODO(), context.TODO(),
@@ -28,73 +21,3 @@ func GetNewClient() *mongo.Client {
} }
return 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
}
+19
View File
@@ -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
}
+29
View File
@@ -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
}
+35
View File
@@ -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()
}
+15
View File
@@ -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
}
+11
View File
@@ -103,6 +103,12 @@ th,
border-radius: var(--radius); border-radius: var(--radius);
} }
.label-input input:focus,
.label-input-error input:focus {
border-color: var(--primary);
outline: solid 3px var(--primary-light);
}
.tags { .tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -181,6 +187,11 @@ th,
cursor: default; cursor: default;
} }
.button-primary:focus,
.button-secondary:focus {
outline: solid 3px var(--primary-light);
}
.tox-tinymce { .tox-tinymce {
border: none !important; border: none !important;
border-radius: 0 !important; border-radius: 0 !important;
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>
+4
View File
@@ -78,6 +78,10 @@ header nav a:hover {
background-color: var(--neutral-verylight); background-color: var(--neutral-verylight);
} }
header nav a:focus {
outline: solid 3px var(--primary-light);
}
.disconnect-link { .disconnect-link {
margin-left: auto; margin-left: auto;
} }
+46 -10
View File
@@ -11,25 +11,61 @@ export interface Article {
online: boolean online: boolean
} }
export async function getArticles(id: string) : Promise<Array<Article>> { export interface GetArticle {
return await fetch(`${baseApiUrl}/articles/${id}`, { content: Article[],
total: number
pagination: {
skip: number,
take: number
links: {
next: string,
previous: string
}
}
}
function fetchArticle(id: string): Promise<Article | GetArticle> {
return new Promise((resolve, reject) => {
fetch(`${baseApiUrl}/articles/${id}`, {
credentials: 'include', credentials: 'include',
method: 'GET', method: 'GET',
}) })
.then(result => result.json()) .then(response => response.json())
.catch(error => { .then(article => resolve(article))
console.error(error) .catch(error => reject(error))
}) })
} }
export async function postArticle(article: Article) : Promise<object> { export function fetchUniqueArticle(id: string): Promise<Article> {
return await fetch(`${baseApiUrl}/articles/${article.titleID}`, { 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', credentials: 'include',
method: 'POST', 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) body: JSON.stringify(article)
}) })
.then(result => result.json()) .then(result => result.json())
.catch(error => { .then(article => resolve(article))
console.error(error) .catch(error => reject(error))
}) })
} }
+7
View File
@@ -0,0 +1,7 @@
import { useRouter } from 'vue-router'
const router = useRouter()
export function reloadPage() {
router.go(0)
}
+86 -35
View File
@@ -1,20 +1,81 @@
<script setup lang="ts"> <script setup lang="ts">
import { getArticles, type Article } from '@/utils/database'; import tabulator_langs from '@/assets/tabulator_langs.json'
import { onMounted, ref, type Ref } from 'vue'; 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 { 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) const tabulator: Ref<Tabulator | undefined> = ref(undefined)
onMounted(async () => { const basicEditButton = getBasicTableButton({ classes: ['button-secondary', 'table-action-button'], text: '✏️' })
articles.value = await getArticles('') 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, { tabulator.value = new Tabulator(table.value, {
data: articles.value,
reactiveData: true,
layout: 'fitColumns', 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: [ columns: [
{ {
title: 'Titre', title: 'Titre',
@@ -46,25 +107,16 @@ onMounted(async () => {
{ {
title: 'Actions', title: 'Actions',
field: 'titleID', field: 'titleID',
formatter: function (cell) { formatter: getButtonsCell,
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
},
headerSort: false headerSort: false
} }
] ],
langs: tabulator_langs
}) })
}
onMounted(async () => {
initTabulatorTable()
}) })
</script> </script>
@@ -75,12 +127,20 @@ onMounted(async () => {
</main> </main>
</template> </template>
<style>
.table-action-button {
margin: 0 4px;
}
</style>
<style scoped> <style scoped>
main { main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
height: 100%;
padding: 32px; padding: 32px;
} }
@@ -90,19 +150,10 @@ main>a {
#table { #table {
width: 100%; width: 100%;
height: 100%;
margin: auto; margin: auto;
} }
.action-buttons {
margin: auto;
width: fit-content;
}
.action-buttons>* {
margin: 0 6px;
}
.status { .status {
text-align: center; text-align: center;
} }
+39 -7
View File
@@ -1,23 +1,50 @@
<script setup lang="ts"> <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 Editor from '@tinymce/tinymce-vue';
import { onMounted, ref, type Ref } from '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 article: Ref<Article | void> = ref()
const editorData: Ref<string> = 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 () => { onMounted(async () => {
const articleFetch = await getArticles(useRoute().params.articleID as string) article.value = await getArticle()
article.value = articleFetch[0] editorData.value = article.value?.content.html ?? ""
editorData.value = article.value.content.html
}) })
function abort() { function abort() {
router.push('/articles')
} }
function saveContent() { 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> </script>
@@ -33,9 +60,14 @@ function saveContent() {
</div> </div>
<aside class="buttons"> <aside class="buttons">
<button @click="saveContent()" class="button-primary">Enregistrer</button> <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> </aside>
</div> </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> </template>
<style scoped> <style scoped>
+10 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <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 { ref, type Ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router';
const router = useRouter() const router = useRouter()
@@ -29,7 +29,7 @@ function isFormEmpty(): boolean {
return title.value === '' || rawTags.value === '' return title.value === '' || rawTags.value === ''
} }
function createArticle() { async function createArticle(): Promise<Article> {
const article: Article = { const article: Article = {
titleID: generateTitleID(title.value), titleID: generateTitleID(title.value),
title: title.value, title: title.value,
@@ -40,7 +40,12 @@ function createArticle() {
online: false, online: false,
tags: tags.value 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}`) router.push(`/articles/edit/${article.titleID}`)
} }
</script> </script>
@@ -48,7 +53,7 @@ function createArticle() {
<template> <template>
<main> <main>
<h2>Créer un nouvel article</h2> <h2>Créer un nouvel article</h2>
<form @submit.prevent="createArticle()"> <form @submit.prevent="formSubmitHandler()">
<div class="inputs-group"> <div class="inputs-group">
<div class="label-input"> <div class="label-input">
<label for="title">Titre de l'article</label> <label for="title">Titre de l'article</label>
+11 -1
View File
@@ -2,9 +2,19 @@
</script> </script>
<template> <template>
<p>home page</p> <main>
<h1>Bienvenue.</h1>
<h2>Vous êtes sur la page d'accueil de GohCMS.</h2>
</main>
</template> </template>
<style scoped> <style scoped>
main {
padding: 64px;
color: var(--neutral-light);
}
main h1 {
margin: 0;
}
</style> </style>
+3 -3
View File
@@ -1,9 +1,9 @@
import { fileURLToPath, URL } from "node:url" import { fileURLToPath, URL } from "node:url"
import { defineConfig, loadEnv } from "vite" import { defineConfig, loadEnv, type UserConfig } from "vite"
import vue from "@vitejs/plugin-vue" import vue from "@vitejs/plugin-vue"
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ command, mode }) => { export default defineConfig(({ command, mode }): UserConfig => {
const env1 = loadEnv(mode, "./../../", "") const env1 = loadEnv(mode, "./../../", "")
const env2 = loadEnv(mode, process.cwd(), "") const env2 = loadEnv(mode, process.cwd(), "")
@@ -21,7 +21,7 @@ export default defineConfig(({ command, mode }) => {
} }
}, },
server: { server: {
port: env1.APP_FRONT_PORT, port: parseInt(env1.APP_FRONT_PORT),
}, },
base: env1.APP_BASE_FRONT_PATH ?? "/" base: env1.APP_BASE_FRONT_PATH ?? "/"
} }