This commit is contained in:
Brieuc Dubois 2023-12-31 05:53:46 +01:00
parent b796195270
commit 4ca04b8cc8
17 changed files with 346 additions and 122 deletions

View File

@ -55,6 +55,16 @@ func InitDB(driver string, connStr string) error {
value TEXT,
FOREIGN KEY(tag_id) REFERENCES tags(id)
);
CREATE TABLE IF NOT EXISTS views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
primary_tag_id INTEGER,
secondary_tag_id INTEGER,
title TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id),
FOREIGN KEY(primary_tag_id) REFERENCES tags(id),
FOREIGN KEY(secondary_tag_id) REFERENCES tags(id)
);
`)
if err != nil {
return err

76
backend/db/views.go Normal file
View File

@ -0,0 +1,76 @@
package db
import "git.bhasher.com/bhasher/focus/backend/types"
func CreateView(v types.View) (int, error) {
res, err := db.Exec("INSERT INTO views (project_id, primary_tag_id, secondary_tag_id, title) VALUES (?, ?, ?, ?)", v.ProjectID, v.PrimaryTagID, v.SecondaryTagID, v.Title)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
return int(id), nil
}
func GetProjectViews(projectID int) ([]types.View, error) {
rows, err := db.Query("SELECT * FROM views WHERE project_id = ?", projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var views []types.View
for rows.Next() {
var v types.View
if err := rows.Scan(&v.ID, &v.ProjectID, &v.PrimaryTagID, &v.SecondaryTagID, &v.Title); err != nil {
return nil, err
}
views = append(views, v)
}
if err = rows.Err(); err != nil {
return nil, err
}
return views, nil
}
func GetView(id int) (*types.View, error) {
rows, err := db.Query("SELECT * FROM views WHERE id = ?", id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
}
var v types.View
rows.Scan(&v.ID, &v.ProjectID, v.PrimaryTagID, v.SecondaryTagID, v.Title)
return &v, nil
}
func UpdateView(v types.View) (int64, error) {
res, err := db.Exec("UPDATE views SET project_id = ?, primary_tag_id = ?, secondary_tag_id = ?, title = ? WHERE id = ?", v.ProjectID, v.PrimaryTagID, v.SecondaryTagID, v.Title, v.ID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func DeleteView(id int) (int64, error) {
res, err := db.Exec("DELETE FROM views WHERE id = ?", id)
if err != nil {
return 0, err
}
return res.RowsAffected()
}

View File

@ -23,10 +23,7 @@ func cardsRouter(router fiber.Router) error {
func CreateCard(c *fiber.Ctx) error {
card := types.Card{}
if err := c.BodyParser(&card); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Cannot parse request",
"trace": fmt.Sprint(err),
})
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cannot parse request"})
}
id, err := db.CreateCard(card)
@ -45,10 +42,7 @@ func CreateCard(c *fiber.Ctx) error {
func GetCard(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid card ID",
"trace": fmt.Sprint(err),
})
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"})
}
card, err := db.GetCard(id)
@ -68,10 +62,7 @@ func GetCard(c *fiber.Ctx) error {
func DeleteCard(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid card ID",
"trace": fmt.Sprint(err),
})
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"})
}
count, err := db.DeleteCard(id)

View File

@ -10,6 +10,7 @@ func v1Router(router fiber.Router) error {
projectsRouter(router.Group("/projects"))
cardsRouter(router.Group("/cards"))
tagsRouter(router.Group("/tags"))
viewsRouter(router.Group("/views"))
return nil
}

View File

@ -17,6 +17,7 @@ func projectsRouter(router fiber.Router) error {
router.Delete("/:id", DeleteProject)
router.Get(":id/cards", GetProjectCards)
router.Get(":id/tags", GetProjectTags)
router.Get(":id/views", GetProjectViews)
return nil
}
@ -175,3 +176,32 @@ func GetProjectTags(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(tags)
}
func GetProjectViews(c *fiber.Ctx) error {
projectID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
exists, err := db.ExistProject(projectID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error finding project",
"trace": fmt.Sprint(err),
})
}
if !exists {
return c.SendStatus(fiber.StatusNotFound)
}
views, err := db.GetProjectViews(projectID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Cannot retrieve views",
"trace": fmt.Sprint(err),
})
}
return c.Status(fiber.StatusOK).JSON(views)
}

107
backend/handlers/views.go Normal file
View File

@ -0,0 +1,107 @@
package handlers
import (
"fmt"
"strconv"
"git.bhasher.com/bhasher/focus/backend/db"
"git.bhasher.com/bhasher/focus/backend/types"
"github.com/gofiber/fiber/v2"
)
func viewsRouter(router fiber.Router) error {
router.Post("/", CreateView)
router.Get("/:id", GetView)
router.Put("/:id", UpdateView)
router.Delete("/:id", DeleteView)
return nil
}
func CreateView(c *fiber.Ctx) error {
view := types.View{}
if err := c.BodyParser(&view); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Cannot parse request",
"trace": fmt.Sprint(err),
})
}
id, err := db.CreateView(view)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Cannot create view",
"trace": fmt.Sprint(err),
})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"id": id,
})
}
func GetView(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid view ID"})
}
view, err := db.GetView(id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Cannot retrieve view",
"trace": fmt.Sprint(err),
})
}
if view == nil {
return c.SendStatus(fiber.StatusNotFound)
}
return c.Status(fiber.StatusOK).JSON(view)
}
func UpdateView(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid view ID"})
}
view := types.View{ID: id}
if err := c.BodyParser(&view); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cannot parse request"})
}
count, err := db.UpdateView(view)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Cannot update view",
"trace": fmt.Sprint(err),
})
}
if count == 0 {
return c.SendStatus(fiber.StatusNotFound)
}
return c.SendStatus(fiber.StatusNoContent)
}
func DeleteView(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid view ID"})
}
count, err := db.DeleteView(id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Cannot delete view",
"trace": fmt.Sprint(err),
})
}
if count == 0 {
return c.SendStatus(fiber.StatusNotFound)
}
return c.SendStatus(fiber.StatusNoContent)
}

9
backend/types/view.go Normal file
View File

@ -0,0 +1,9 @@
package types
type View struct {
ID int `json:"id"`
ProjectID int `json:"project_id"`
PrimaryTagID int `json:"primary_tag_id"`
SecondaryTagID int `json:"secondary_tag_id"`
Title string `json:"title"`
}

View File

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

View File

@ -1,6 +1,6 @@
<script lang="ts">
import ModalTags from './modal_tags.svelte';
import type { Card, MeTag } from '../stores/interfaces';
import type { Card } from '../stores/interfaces';
import api, { processError } from '../utils/api';
import status from '../utils/status';

View File

@ -1,5 +1,5 @@
<script lang="ts">
import type { TagValue, MeTag } from '../stores/interfaces';
import type { TagValue } from '../stores/interfaces';
import projectTags from '../stores/projectTags';
import api, { processError } from '../utils/api';
import status from '../utils/status';

View File

@ -1,7 +1,7 @@
<script lang="ts">
import ModalTag from './modal_tag.svelte';
import status from '../utils/status';
import type { Card, MeTag } from '../stores/interfaces';
import type { Card } from '../stores/interfaces';
import api, { processError } from '../utils/api';
export let card: Card;

View File

@ -1,16 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import CardC from './card.svelte';
import {
type Project,
type Card,
parseCards,
type MeTag,
parseMeTags
} from '../stores/interfaces';
import { type Project, type Card, parseCards, type View } from '../stores/interfaces';
import status from '../utils/status';
import api, { processError } from '../utils/api';
import projectTags from '../stores/projectTags';
import currentView from '../stores/currentView';
import Card from './card.svelte';
export let projectId: number;
@ -80,6 +76,31 @@
cards = cards.filter((card) => card.id !== cardID);
}
let view: View | null = null;
let columns: { id: number; title: string; cards: Card[] }[] = [];
currentView.subscribe((v) => {
view = v;
if (!v) return;
let primary_tag_id = v.primary_tag_id;
columns = $projectTags[primary_tag_id].options.map((o) => {
return {
id: o.id,
title: o.value,
cards: cards.filter((c) => c.tags.map((t) => t.option_id).includes(o.id))
};
});
columns.push({
id: -1,
title: 'No tag',
cards: cards.filter((c) => {
const tag = c.tags.find((t) => t.tag_id === primary_tag_id);
return tag?.option_id == -1;
})
});
});
</script>
<svelte:head>
@ -94,16 +115,51 @@
<h2>{project.title}</h2>
<button on:click={newCard}>New card</button>
</header>
<ul>
{#if cards}
{#each cards as card}
<CardC
{card}
showModal={modalID === card.id}
onDelete={async () => await deleteCard(card.id)}
/>
{#if view}
<div class="grid">
{#each columns as column}
<div class="column">
<h3>{column.title}</h3>
<ul>
{#each column.cards as card}
<CardC
{card}
showModal={modalID === card.id}
onDelete={async () => await deleteCard(card.id)}
/>
{/each}
</ul>
</div>
{/each}
{/if}
</ul>
</div>
{:else}
<ul>
{#if cards}
{#each cards as card}
<CardC
{card}
showModal={modalID === card.id}
onDelete={async () => await deleteCard(card.id)}
/>
{/each}
{/if}
</ul>
{/if}
</div>
{/if}
<style>
#project .grid {
display: flex;
flex-direction: row;
}
#project .column {
width: 200px;
margin: 0 10px;
}
#project .column h3 {
text-align: center;
}
</style>

View File

@ -1,73 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import api, { processError } from '../utils/api';
import type { Project } from '../stores/interfaces';
import type { View } from '../stores/interfaces';
import currentView from '../stores/currentView';
let newProject = false;
let editProject: number | undefined = undefined;
let projects: Project[];
export let projectID: number;
let views: View[];
onMount(async () => {
const response = await api.get(`/v1/projects`);
const response = await api.get(`/v1/projects/${projectID}/views`);
if (response.status !== 200) {
processError(response, 'Failed to fetch projects');
processError(response, 'Failed to fetch views');
return;
}
projects = response.data;
views = 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) {
if (event.key === 'Enter' && event.target) {
if (id !== undefined) {
updateProject({
id: id,
title: (event.target as HTMLInputElement).value
});
editProject = undefined;
} else {
createNewProject((event.target as HTMLInputElement).value);
newProject = false;
}
}
}
function createNewProject(value: string) {
createProject({
title: value,
id: undefined
});
}
</script>
<svelte:head>
@ -84,38 +33,18 @@
</div>
<div class="boards">
<h2>Projects</h2>
{#if projects}
{#if views}
<ul>
{#each projects as project}
{#each views as view}
<li>
{#if editProject === project.id && editProject !== undefined}
<input
type="text"
on:keydown={(e) => handleKeydown(e, project.id)}
value={project.title}
class="edit-input"
/>
{:else}
<a href="/{project.id}">{project.title}</a>
<img src="img/edit-icon.svg" alt="" on:click={() => (editProject = project.id)} />
{/if}
<span
on:click={() => {
currentView.set(view);
}}>{view.title}</span
>
</li>
{/each}
{#if newProject}
<li>
<input
type="text"
placeholder="Enter project title"
on:keydown={handleKeydown}
style="padding: 8px; border: 1px solid #ccc; border-radius: 4px; width: 100%;"
/>
</li>
{/if}
</ul>
{/if}
</div>
<div class="bottom-links">
<span on:click={() => (newProject = true)}>New project</span>
<span>Settings</span>
</div>
</div>

View File

@ -3,13 +3,16 @@
import Sidebar from '../../components/sidebar.svelte';
import { page } from '$app/stores';
import { SvelteToast } from '@zerodevx/svelte-toast';
import type { View } from '../../stores/interfaces';
let projectId: number = +$page.params.project;
let projectID: number = +$page.params.project;
let currentView: View;
</script>
<div id="projectPage">
<Sidebar />
<Project {projectId} />
<Sidebar {projectID} />
<Project projectId={projectID} />
</div>
<SvelteToast />

View File

@ -0,0 +1,4 @@
import { writable } from "svelte/store";
import type { View } from "./interfaces";
export default writable(null as View | null);

View File

@ -34,6 +34,14 @@ export interface TagOption {
value: string;
}
export interface View {
id: number;
project_id: number;
primary_tag_id: number;
secondary_tag_id: number;
title: string;
}
export function parseCard (c: any) {
let card: Card = c;
if (card.tags == null) card.tags = [];

View File

@ -3,7 +3,7 @@
border-radius: 6px;
border: 1px solid #555;
margin: 10px;
width: 200px;
/* width: 200px; */
font-family: "Open Sans", sans-serif;
font-size: 14px;
}