API fixes & projects page

This commit is contained in:
Brieuc Dubois 2023-12-30 23:32:33 +01:00
parent 0809efbf81
commit c69dadc4bf
20 changed files with 466 additions and 128 deletions

View File

@ -112,5 +112,5 @@ func UpdateCard(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }
return c.SendStatus(fiber.StatusOK) return c.SendStatus(fiber.StatusNoContent)
} }

View File

@ -18,6 +18,7 @@
"@types/eslint": "8.56.0", "@types/eslint": "8.56.0",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
"@zerodevx/svelte-toast": "^0.9.5",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",
@ -1144,6 +1145,15 @@
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
"dev": true "dev": true
}, },
"node_modules/@zerodevx/svelte-toast": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/@zerodevx/svelte-toast/-/svelte-toast-0.9.5.tgz",
"integrity": "sha512-JLeB/oRdJfT+dz9A5bgd3Z7TuQnBQbeUtXrGIrNWMGqWbabpepBF2KxtWVhL2qtxpRqhae2f6NAOzH7xs4jUSw==",
"dev": true,
"peerDependencies": {
"svelte": "^3.57.0 || ^4.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.11.2", "version": "8.11.2",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz",

View File

@ -20,6 +20,7 @@
"@types/eslint": "8.56.0", "@types/eslint": "8.56.0",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
"@zerodevx/svelte-toast": "^0.9.5",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",

View File

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import axios from 'axios';
import type { Card } from '../stores/interfaces'; import type { Card } from '../stores/interfaces';
import ModalCard from './modal_card.svelte'; import ModalCard from './modal_card.svelte';

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import axios from 'axios';
import ModalTags from './modal_tags.svelte'; import ModalTags from './modal_tags.svelte';
import type { Card } from '../stores/interfaces'; import type { Card } from '../stores/interfaces';
import { backend } from '../stores/config'; import api, { processError } from '../utils/api';
import status from '../utils/status';
export let show: boolean; export let show: boolean;
export let card: Card; export let card: Card;
@ -11,18 +11,23 @@
let tempCard: Card = { ...card }; let tempCard: Card = { ...card };
function save(closeModal: boolean = true) { async function save(closeModal: boolean = true) {
if ( if (
card.project_id != tempCard.project_id || card.project_id != tempCard.project_id ||
card.title !== tempCard.title || card.title !== tempCard.title ||
card.content !== tempCard.content card.content !== tempCard.content
) { ) {
axios.put(`${backend}/api/v1/cards/${card.id}`, { const response = await api.put(`/v1/cards/${card.id}`, {
project_id: tempCard.project_id, project_id: tempCard.project_id,
title: tempCard.title, title: tempCard.title,
content: tempCard.content content: tempCard.content
}); });
if (response.status !== status.NoContent) {
processError(response, 'Failed to update card');
return;
}
card = { ...tempCard }; card = { ...tempCard };
} }
if (closeModal) show = false; if (closeModal) show = false;

View File

@ -1,31 +1,44 @@
<script lang="ts"> <script lang="ts">
import axios from 'axios';
import { backend } from '../stores/config';
import type { Tag } from '../stores/interfaces'; import type { Tag } from '../stores/interfaces';
import api, { processError } from '../utils/api';
import status from '../utils/status';
export let tag: Tag; export let tag: Tag;
let newValue: string = tag.value; let newValue: string = tag.value;
export let removeTag: (id: number) => void; export let removeTag: (id: number) => void;
function saveTag() { async function saveTag() {
if (tag.value === newValue) return; if (tag.value === newValue) return;
// DELETE // DELETE
if (tag.value !== '' && newValue === '') { if (tag.value !== '' && newValue === '') {
axios.delete(`${backend}/api/v1/cards/${tag.card_id}/tags/${tag.tag_id}`); const response = await api.delete(`/v1/cards/${tag.card_id}/tags/${tag.tag_id}`);
if (response.status !== status.NoContent) {
processError(response, 'Failed to delete tag');
return; return;
} }
}
// CREATE // CREATE
if (tag.value === '' && newValue !== '') { else if (tag.value === '' && newValue !== '') {
axios.post(`${backend}/api/v1/cards/${tag.card_id}/tags/${tag.tag_id}`, { const response = await api.post(`/v1/cards/${tag.card_id}/tags/${tag.tag_id}`, {
value: newValue value: newValue
}); });
if (response.status !== status.Created) {
processError(response, 'Failed to create tag');
return; return;
} }
}
// UPDATE // UPDATE
axios.put(`${backend}/api/v1/cards/${tag.card_id}/tags/${tag.tag_id}`, { else {
const response = await api.put(`/v1/cards/${tag.card_id}/tags/${tag.tag_id}`, {
value: newValue value: newValue
}); });
if (response.status !== status.NoContent) {
processError(response, 'Failed to update tag');
return;
}
}
tag.value = newValue; tag.value = newValue;
} }

View File

@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import axios from 'axios';
import ModalTag from './modal_tag.svelte'; import ModalTag from './modal_tag.svelte';
import { backend } from '../stores/config';
import status from '../utils/status'; import status from '../utils/status';
import type { Card } from '../stores/interfaces'; import type { Card } from '../stores/interfaces';
import api, { processError } from '../utils/api';
export let card: Card; export let card: Card;
@ -12,13 +11,16 @@
async function addTag() { async function addTag() {
if (newTagName === '') return; if (newTagName === '') return;
const response = await axios.post(`${backend}/api/v1/tags`, { const response = await api.post(`/v1/tags`, {
project_id: card.project_id, project_id: card.project_id,
title: newTagName, title: newTagName,
type: 0 type: 0
}); });
if (response.status !== status.Created) return console.error(response); if (response.status !== status.Created) {
processError(response, 'Failed to create tag');
return;
}
const id = response.data.id; const id = response.data.id;
card.tags = [...card.tags, { card_id: card.id, tag_id: id, tag_title: newTagName, value: '' }]; card.tags = [...card.tags, { card_id: card.id, tag_id: id, tag_title: newTagName, value: '' }];

View File

@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import axios from 'axios';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import CardC from './card.svelte'; import CardC from './card.svelte';
import { backend } from '../stores/config';
import { type Project, type Card, parseCards } from '../stores/interfaces'; import { type Project, type Card, parseCards } from '../stores/interfaces';
import status from '../utils/status'; import status from '../utils/status';
import api, { processError } from '../utils/api';
export let projectId: number; export let projectId: number;
@ -12,30 +11,38 @@
let cards: Card[]; let cards: Card[];
onMount(async () => { onMount(async () => {
let response = await axios.get(`${backend}/api/v1/projects/${projectId}`); let response = await api.get(`/v1/projects/${projectId}`);
if (response.status !== status.OK) {
processError(response, 'Failed to fetch project');
return;
}
project = response.data; project = response.data;
response = await axios.get(`${backend}/api/v1/projects/${projectId}/cards`); response = await api.get(`/v1/projects/${projectId}/cards`, {
validateStatus: () => true
});
if (response.status === status.OK) { if (response.status === status.OK) {
cards = parseCards(response.data); cards = parseCards(response.data);
} else { } else {
console.error(response.data); cards = [];
processError(response, 'Failed to fetch cards');
} }
}); });
let modalID = -1; let modalID = -1;
async function newCard() { async function newCard() {
const response = await axios.post(`${backend}/api/v1/cards`, { const response = await api.post(`/v1/cards`, {
project_id: projectId, project_id: projectId,
title: 'Untitled', title: 'Untitled',
content: '' content: ''
}); });
if (response.status !== status.Created) { if (response.status !== status.Created) {
console.error(response.data); processError(response, 'Failed to create card');
return; return;
} }
@ -54,10 +61,10 @@
} }
async function deleteCard(cardID: number) { async function deleteCard(cardID: number) {
const response = await axios.delete(`${backend}/api/v1/cards/${cardID}`); const response = await api.delete(`/v2/cards/${cardID}`);
if (response.status !== status.NoContent) { if (response.status !== status.NoContent) {
console.error(response.data); processError(response, 'Failed to delete card');
return; return;
} }

View File

@ -0,0 +1,100 @@
<script lang="ts">
import type { Project } from '../stores/interfaces';
import api, { processError } from '../utils/api';
import status from '../utils/status';
export let project: Project;
export let deleteProject: (project: Project) => void;
let edit = false;
let newTitle = project.title;
function focus(el: HTMLElement) {
el.focus();
}
async function updateProject() {
if (newTitle === project.title) {
edit = false;
return;
}
const response = await api.put(`/v1/projects/${project.id}`, { title: newTitle });
if (response.status !== status.NoContent) {
processError(response, 'Failed to update project');
return;
}
console.log(newTitle);
project.title = newTitle;
console.log(project.title);
edit = false;
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-element-to-interactive-role -->
<li>
{#if edit}
<input
type="text"
bind:value={newTitle}
on:blur={updateProject}
on:keydown={(e) => {
if (e.key === 'Enter') {
updateProject();
}
}}
use:focus
/>
{:else}
<div
class="title"
on:click={() => (location.href = `/${project.id}`)}
on:keydown={(e) => {
if (e.key === 'Enter') {
location.href = `/${project.id}`;
}
}}
tabindex="0"
role="button"
>
{project.title}
</div>
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="buttons" on:keydown|stopPropagation>
{#if !edit}
<img
src="/img/edit-icon.svg"
alt="Edit"
class="button"
on:click={() => (edit = !edit)}
role="button"
tabindex="0"
on:keydown|stopPropagation={(e) => {
if (e.key === 'Enter') {
edit = !edit;
}
}}
/>
{/if}
<img
src="/img/delete-icon.svg"
alt="Delete"
class="button"
on:click={() => deleteProject(project)}
role="button"
tabindex="0"
on:keydown={(e) => {
if (e.key === 'Enter') {
deleteProject(project);
}
}}
/>
</div>
</li>

View File

@ -1,13 +1,56 @@
<script lang="ts"> <script lang="ts">
import { projects } from '../stores/projects'; import { onMount } from 'svelte';
import api, { processError } from '../utils/api';
import type { Project } from '../stores/interfaces';
let newProject = false; let newProject = false;
let editProject: number | undefined = undefined; let editProject: number | undefined = undefined;
let projects: Project[];
onMount(async () => {
const response = await api.get(`/v1/projects`);
if (response.status !== 200) {
processError(response, 'Failed to fetch projects');
return;
}
projects = response.data;
});
async function createProject(project: Project) {
const response = await api.post(`/v1/projects`, project);
if (response.status !== 201) {
processError(response, 'Failed to create project');
return;
}
project.id = response.data.id;
projects = [...projects, project];
}
async function updateProject(project: Project) {
const response = await api.put(`/v1/projects/${project.id}`, project);
if (response.status !== 204) {
processError(response, 'Failed to update project');
return;
}
projects = projects.map((p) => {
if (p.id === project.id) {
return project;
}
return p;
});
}
function handleKeydown(event: KeyboardEvent, id: number | undefined = undefined) { function handleKeydown(event: KeyboardEvent, id: number | undefined = undefined) {
if (event.key === 'Enter' && event.target) { if (event.key === 'Enter' && event.target) {
if (id !== undefined) { if (id !== undefined) {
projects.edit({ updateProject({
id: id, id: id,
title: (event.target as HTMLInputElement).value title: (event.target as HTMLInputElement).value
}); });
@ -20,7 +63,7 @@
} }
function createNewProject(value: string) { function createNewProject(value: string) {
projects.add({ createProject({
title: value, title: value,
id: undefined id: undefined
}); });
@ -40,15 +83,12 @@
</a> </a>
</div> </div>
<div class="boards"> <div class="boards">
{#await projects.init()}
<p>Loading ...</p>
{:then}
<h2>Projects</h2> <h2>Projects</h2>
{#if $projects} {#if projects}
<ul> <ul>
{#each $projects as project} {#each projects as project}
<li> <li>
{#if editProject === project.id} {#if editProject === project.id && editProject !== undefined}
<input <input
type="text" type="text"
on:keydown={(e) => handleKeydown(e, project.id)} on:keydown={(e) => handleKeydown(e, project.id)}
@ -73,9 +113,6 @@
{/if} {/if}
</ul> </ul>
{/if} {/if}
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}
</div> </div>
<div class="bottom-links"> <div class="bottom-links">
<span on:click={() => (newProject = true)}>New project</span> <span on:click={() => (newProject = true)}>New project</span>

View File

@ -1,6 +1,77 @@
<script> <script lang="ts">
import Sidebar from "../components/sidebar.svelte"; import { SvelteToast } from '@zerodevx/svelte-toast';
import type { Project } from '../stores/interfaces';
import { onMount } from 'svelte';
import api, { processError } from '../utils/api';
import SelectProject from '../components/selectProject.svelte';
let projects: Project[];
onMount(async () => {
const response = await api.get(`/v1/projects`);
if (response.status !== 200) {
processError(response, 'Failed to fetch projects');
return;
}
projects = response.data || [];
});
async function deleteProject(project: Project) {
if (!confirm(`Are you sure you want to delete ${project.title}?`)) {
return;
}
const response = await api.delete(`/v1/projects/${project.id}`);
if (response.status !== 204) {
processError(response, 'Failed to delete project');
return;
}
projects = projects.filter((p) => p.id !== project.id);
}
async function createProject() {
const response = await api.post(`/v1/projects`, {
title: 'New Project'
});
if (response.status !== 201) {
processError(response, 'Failed to create project');
return;
}
projects = [...projects, response.data];
}
</script> </script>
<Sidebar /> <svelte:head>
<link rel="stylesheet" type="text/css" href="/css/projects.css" />
</svelte:head>
<section id="projects">
<h2>Projects</h2>
<ul>
{#if projects}
{#each projects as project}
<SelectProject {project} {deleteProject} />
{/each}
{/if}
</ul>
<div
id="add"
tabindex="0"
role="button"
on:click={createProject}
on:keydown={(e) => {
if (e.key === 'Enter') {
createProject();
}
}}
>
<img src="/img/add-icon.svg" alt="Add" width="30" />
</div>
</section>
<SvelteToast />

View File

@ -2,6 +2,7 @@
import Project from '../../components/project.svelte'; import Project from '../../components/project.svelte';
import Sidebar from '../../components/sidebar.svelte'; import Sidebar from '../../components/sidebar.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { SvelteToast } from '@zerodevx/svelte-toast';
let projectId: number = +$page.params.project; let projectId: number = +$page.params.project;
</script> </script>
@ -10,6 +11,7 @@
<Sidebar /> <Sidebar />
<Project {projectId} /> <Project {projectId} />
</div> </div>
<SvelteToast />
<style> <style>
#projectPage { #projectPage {

View File

@ -1,5 +1,7 @@
import { toastAlert } from "../utils/toasts";
export interface Project { export interface Project {
id: number | undefined; id: number;
title: string; title: string;
} }
@ -26,5 +28,16 @@ export function parseCard (c: any) {
export function parseCards (cards: any) { export function parseCards (cards: any) {
if (cards == null) return []; if (cards == null) return [];
return cards.map(parseCard);
let cardsArray;
try {
cardsArray = JSON.parse(cards);
} catch (e) {
toastAlert('Error', 'Could not parse cards');
return [];
}
if (cardsArray == null) return [];
return cardsArray.map(parseCard);
} }

View File

@ -1,55 +0,0 @@
import axios from "axios";
import { writable } from "svelte/store";
import { backend } from "./config";
export const projects = getProjects();
interface Project {
id: number | undefined,
title: string,
}
function getProjects() {
const { subscribe, set, update } = writable([] as Project[]);
return {
subscribe,
init: async () => {
const response = await axios.get(`${backend}/api/v1/projects`);
if(response.status < 303) {
const data: Project[] = response.data;
set(data);
return data;
}
},
add: async (project: Project) => {
const response = await axios.post(`${backend}/api/v1/projects`, project);
if(response.status < 303) {
project.id = response.data["id"];
update((oldProjects) => {
oldProjects.push(project)
return oldProjects;
});
}
},
edit: async (project: Project) => {
const response = await axios.put(`${backend}/api/v1/projects/${project.id}`, project)
if(response.status < 303) {
update((oldProjects: Project[]) => {
for(let p of oldProjects){
if(p.id === project.id){
p.title = project.title;
}
}
return oldProjects;
});
}
}
}
}

31
frontend/src/utils/api.ts Normal file
View File

@ -0,0 +1,31 @@
import axios, { Axios, type AxiosResponse } from "axios";
import { backend } from "../stores/config";
import { toastAlert } from "./toasts";
export default new Axios({
...axios.defaults,
baseURL: backend + '/api',
validateStatus: () => true,
headers: {
'Content-Type': 'application/json'
},
});
export function processError (response: AxiosResponse<any, any>, message: string = '') {
let title = `${response.status} ${response.statusText}`;
let subtitle = message;
console.log(response.headers)
if(response.headers["content-type"] === "application/json") {
const parsed = response.data;
subtitle = parsed.error;
if(response.data.trace) {
subtitle += '<br><br>' + parsed.trace;
}
}
axios.get(backend + '/api/trace');
toastAlert(title, subtitle);
}

View File

@ -0,0 +1,18 @@
import { toast } from '@zerodevx/svelte-toast';
export function toastAlert(title: string, subtitle: string = '', persistant: boolean = false) {
toast.push(
`<strong>${title}</strong><br>${subtitle}`,
{
theme: {
'--toastBackground': '#ff4d4f',
'--toastBarBackground': '#d32f2f',
'--toastColor': '#fff',
},
initial: persistant ? 0 : 1,
next: 0,
duration: 10000,
pausable: true,
},
)
}

View File

@ -0,0 +1,72 @@
#projects {
margin: 40px;
}
#projects h2 {
text-align: center;
margin-bottom: 40px;
}
#projects ul {
list-style: none;
padding: 0;
margin: 0;
}
#projects li {
cursor: pointer;
margin: 10px 0;
border: 1px solid #555;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between
}
#projects .title {
font-weight: bold;
padding: 20px;
width: 100%;
}
#projects .title:hover {
background-color: #303030;
}
#projects .buttons {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
#projects li img {
padding: 20px;
}
#projects li img:hover {
background-color: #333;
}
#projects input {
padding: 20px;
width: 100%;
background-color: #333;
color: inherit;
font-weight: bold;
font-size: inherit;
}
#projects #add {
width: 100%;
display: flex;
justify-content: center;
padding: 20px 0;
cursor: pointer;
}
#projects #add:hover {
background-color: #303030;
}

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" stroke="white" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m2-2h10a2 2 0 0 1 2 2v2H5V6a2 2 0 0 1 2-2z"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>

After

Width:  |  Height:  |  Size: 420 B

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="18" viewBox="0 0 576 512"> <svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 576 512">
<path <path
d="M402.6 83.2l90.2 90.2c3.8 3.8 3.8 10 0 13.8L274.4 405.6l-92.8 10.3c-12.4 1.4-22.9-9.1-21.5-21.5l10.3-92.8L388.8 83.2c3.8-3.8 10-3.8 13.8 0zm162-22.9l-48.8-48.8c-15.2-15.2-39.9-15.2-55.2 0l-35.4 35.4c-3.8 3.8-3.8 10 0 13.8l90.2 90.2c3.8 3.8 10 3.8 13.8 0l35.4-35.4c15.2-15.3 15.2-40 0-55.2zM384 346.2V448H64V128h229.8c3.2 0 6.2-1.3 8.5-3.5l40-40c7.6-7.6 2.2-20.5-8.5-20.5H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V306.2c0-10.7-12.9-16-20.5-8.5l-40 40c-2.2 2.3-3.5 5.3-3.5 8.5z" d="M402.6 83.2l90.2 90.2c3.8 3.8 3.8 10 0 13.8L274.4 405.6l-92.8 10.3c-12.4 1.4-22.9-9.1-21.5-21.5l10.3-92.8L388.8 83.2c3.8-3.8 10-3.8 13.8 0zm162-22.9l-48.8-48.8c-15.2-15.2-39.9-15.2-55.2 0l-35.4 35.4c-3.8 3.8-3.8 10 0 13.8l90.2 90.2c3.8 3.8 10 3.8 13.8 0l35.4-35.4c15.2-15.3 15.2-40 0-55.2zM384 346.2V448H64V128h229.8c3.2 0 6.2-1.3 8.5-3.5l40-40c7.6-7.6 2.2-20.5-8.5-20.5H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V306.2c0-10.7-12.9-16-20.5-8.5l-40 40c-2.2 2.3-3.5 5.3-3.5 8.5z"
fill="#aaa" /> fill="#fff" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 645 B

After

Width:  |  Height:  |  Size: 645 B