Add tags
This commit is contained in:
parent
dabcd7b75a
commit
4c02dbb1d4
7 changed files with 221 additions and 64 deletions
|
@ -7,4 +7,4 @@ indent_style = space
|
|||
indent_size = 4
|
||||
|
||||
[*.{yaml,yml}]
|
||||
indent_size = 2
|
||||
indent_size = 2
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
<script lang="ts">
|
||||
let count: number = 0
|
||||
const increment = () => {
|
||||
count += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={increment}>
|
||||
count is {count}
|
||||
</button>
|
|
@ -20,13 +20,20 @@
|
|||
let newItemName = "";
|
||||
let newItemDate = (new Date()).toISOString().split("T")[0];
|
||||
let newItemDuration = "1h";
|
||||
let newItemRawTags = "";
|
||||
$: newItemTags = newItemRawTags.split(";").filter(x => x);
|
||||
|
||||
function createItem() {
|
||||
$createMutation.mutate({
|
||||
name: newItemName,
|
||||
date: newItemDate,
|
||||
duration: newItemDuration
|
||||
duration: newItemDuration,
|
||||
tags: newItemTags
|
||||
});
|
||||
|
||||
newItemName = "";
|
||||
newItemDuration = "1h";
|
||||
document.getElementById("name-input").focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -62,7 +69,12 @@
|
|||
<div class="vstack gap-3 justify-content-center h-100">
|
||||
{#each items as item}
|
||||
<div class="hstack gap-3">
|
||||
<span class="me-auto">{item.name}</span>
|
||||
<span class="me-auto">
|
||||
{item.name}
|
||||
{#each item.tags as tag}
|
||||
<span class="badge bg-info me-1">{tag.name}</span>
|
||||
{/each}
|
||||
</span>
|
||||
<button type="button" class="btn btn-danger btn-sm" on:click={() => {
|
||||
if (!confirm(`Are you sure you want to delete "${item.name}"?`)) return;
|
||||
$deleteMutation.mutate(item.id)
|
||||
|
@ -79,7 +91,7 @@
|
|||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-6">
|
||||
<h2>New</h2>
|
||||
<form on:submit|preventDefault={createItem}>
|
||||
|
@ -95,15 +107,24 @@
|
|||
<label for="duration-input" class="form-label">Duration</label>
|
||||
<input type="text" class="form-control" id="duration-input" bind:value={newItemDuration} required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tags-input" class="form-label">Tags</label>
|
||||
<input type="text" class="form-control" id="tags-input" bind:value={newItemRawTags} />
|
||||
<div class="form-text">
|
||||
{#each newItemTags as tag}
|
||||
<span class="badge bg-info me-1">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h2>Raw Data</h2>
|
||||
{#if $queryResult.data}
|
||||
<div class="small mt-2">
|
||||
<pre><code>{JSON.stringify($queryResult.data, null, 2)}</code></pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
const SCHEDULE_DAYS = 14;
|
||||
const API_URL = import.meta.env.PROD ? "/api" : import.meta.env.VITE_API_URL;
|
||||
|
||||
type ScheduleTag = {
|
||||
id: number,
|
||||
createdAt: string,
|
||||
updatedAt: string,
|
||||
name: string
|
||||
}
|
||||
|
||||
type ScheduleItem = {
|
||||
id: number,
|
||||
createdAt: string,
|
||||
|
@ -9,12 +16,14 @@ type ScheduleItem = {
|
|||
description: string | null,
|
||||
duration: string,
|
||||
date: string
|
||||
tags: ScheduleTag[]
|
||||
};
|
||||
|
||||
type Schedule = { [date: string]: ScheduleItem[] };
|
||||
|
||||
export async function getSchedule(): Promise<Schedule> {
|
||||
const res = await fetch(`${API_URL}/schedule?days=${SCHEDULE_DAYS}`);
|
||||
|
||||
|
||||
if (res?.ok) {
|
||||
return await res.json();
|
||||
} else {
|
||||
|
@ -22,7 +31,7 @@ export async function getSchedule(): Promise<Schedule> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function createScheduleItem(data: { name: string, duration: string, date: string }) {
|
||||
export async function createScheduleItem(data: { name: string, duration: string, date: string, tags: string[] }) {
|
||||
const res = await fetch(`${API_URL}/items`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -33,7 +42,7 @@ export async function createScheduleItem(data: { name: string, duration: string,
|
|||
date: new Date(data.date).toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
if (res?.ok) {
|
||||
return await res.json();
|
||||
} else {
|
||||
|
@ -45,10 +54,10 @@ export async function deleteScheduleItem(id: number) {
|
|||
const res = await fetch(`${API_URL}/items/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
|
||||
if (res?.ok) {
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Invalid response code ${res?.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,17 +4,18 @@ import (
|
|||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func GetScheduleItems(db *gorm.DB, fromDate time.Time, toDate time.Time) ([]ScheduleItem, error) {
|
||||
var items []ScheduleItem
|
||||
res := db.Where("date BETWEEN ? AND ?", fromDate.Format(time.DateOnly), toDate.Format(time.DateOnly)).Find(&items)
|
||||
res := db.Preload("Tags").Where("date BETWEEN ? AND ?", fromDate.Format(time.DateOnly), toDate.Format(time.DateOnly)).Find(&items)
|
||||
return items, res.Error
|
||||
}
|
||||
|
||||
func GetScheduleItem(db *gorm.DB, id int) (*ScheduleItem, error) {
|
||||
var item ScheduleItem
|
||||
res := db.First(&item, id)
|
||||
res := db.Preload("Tags").First(&item, id)
|
||||
|
||||
if res.RowsAffected < 1 {
|
||||
return nil, nil
|
||||
|
@ -37,15 +38,83 @@ func DeleteScheduleItem(db *gorm.DB, id int) (bool, error) {
|
|||
return true, nil
|
||||
}
|
||||
|
||||
func CreateScheduleItem(db *gorm.DB, name string, description *string, duration Duration, date time.Time) (ScheduleItem, error) {
|
||||
func CreateScheduleItem(db *gorm.DB, name string, description *string, duration Duration, date time.Time, tagNames []string) (ScheduleItem, error) {
|
||||
var tags = []ScheduleTag{}
|
||||
var err error
|
||||
|
||||
if len(tagNames) > 0 {
|
||||
tags, err = UpsertScheduleTags(db, tagNames)
|
||||
if err != nil {
|
||||
return ScheduleItem{}, err
|
||||
}
|
||||
}
|
||||
|
||||
item := ScheduleItem{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Duration: duration,
|
||||
Date: date,
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
res := db.Create(&item)
|
||||
|
||||
return item, res.Error
|
||||
}
|
||||
|
||||
func GetScheduleTags(db *gorm.DB) ([]ScheduleTag, error) {
|
||||
var tags []ScheduleTag
|
||||
res := db.Find(&tags)
|
||||
return tags, res.Error
|
||||
}
|
||||
|
||||
func GetScheduleTag(db *gorm.DB, id int) (*ScheduleTag, error) {
|
||||
var tag ScheduleTag
|
||||
res := db.First(&tag, id)
|
||||
|
||||
if res.RowsAffected < 1 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &tag, res.Error
|
||||
}
|
||||
|
||||
func DeleteScheduleTag(db *gorm.DB, id int) (bool, error) {
|
||||
res := db.Delete(&ScheduleTag{}, id)
|
||||
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
|
||||
if res.RowsAffected < 1 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func CreateScheduleTag(db *gorm.DB, name string) (ScheduleTag, error) {
|
||||
tag := ScheduleTag{
|
||||
Name: name,
|
||||
}
|
||||
|
||||
res := db.Create(&tag)
|
||||
|
||||
return tag, res.Error
|
||||
}
|
||||
|
||||
func UpsertScheduleTags(db *gorm.DB, names []string) ([]ScheduleTag, error) {
|
||||
var tags = []ScheduleTag{}
|
||||
|
||||
for _, name := range names {
|
||||
tags = append(tags, ScheduleTag{Name: name})
|
||||
}
|
||||
|
||||
res := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&tags)
|
||||
return tags, res.Error
|
||||
}
|
||||
|
||||
func Cleanup(db *gorm.DB) error {
|
||||
res := db.Exec("select * from schedule_tags where id not in (select schedule_tag_id from schedule_item_tags);")
|
||||
return res.Error
|
||||
}
|
||||
|
|
130
main.go
130
main.go
|
@ -1,15 +1,16 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"log"
|
||||
"embed"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
@ -17,18 +18,24 @@ import (
|
|||
var f embed.FS
|
||||
|
||||
type Model struct {
|
||||
ID uint `gorm:"primaryKey;not null" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
ID uint `gorm:"primaryKey;not null" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type ScheduleItem struct {
|
||||
Model
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Duration Duration `gorm:"not null;default:\"1h\"" json:"duration"`
|
||||
Date time.Time `gorm:"not null" json:"date"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Duration Duration `gorm:"not null;default:\"1h\"" json:"duration"`
|
||||
Date time.Time `gorm:"not null" json:"date"`
|
||||
Tags []ScheduleTag `gorm:"many2many:schedule_item_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"tags"`
|
||||
}
|
||||
|
||||
type ScheduleTag struct {
|
||||
Model
|
||||
Name string `gorm:"not null;unique;index" json:"name"`
|
||||
}
|
||||
|
||||
type NewScheduleItem struct {
|
||||
|
@ -36,6 +43,11 @@ type NewScheduleItem struct {
|
|||
Description *string `json:"description" form:"description"`
|
||||
Duration *Duration `json:"duration" form:"duration"`
|
||||
Date *time.Time `json:"date" form:"date"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type NewScheduleTag struct {
|
||||
Name *string `json:"name" form:"name"`
|
||||
}
|
||||
|
||||
func getDateValues(c *fiber.Ctx) (time.Time, time.Time, error) {
|
||||
|
@ -63,7 +75,7 @@ func getDateValues(c *fiber.Ctx) (time.Time, time.Time, error) {
|
|||
func dateRange(from time.Time, to time.Time) []time.Time {
|
||||
var dates []time.Time
|
||||
|
||||
for d := from; d.After(to) == false; d = d.AddDate(0, 0, 1) {
|
||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
|
||||
|
@ -76,13 +88,13 @@ func main() {
|
|||
log.Fatalf("failed to open database: %v", err)
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(&ScheduleItem{})
|
||||
err = db.AutoMigrate(&ScheduleItem{}, &ScheduleTag{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to migrate database: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
|
||||
app.Use(cors.New())
|
||||
|
||||
app.Get("/api/items", func(c *fiber.Ctx) error {
|
||||
|
@ -109,7 +121,7 @@ func main() {
|
|||
return err
|
||||
}
|
||||
|
||||
if input.Name == nil || len(*input.Name) < 0 {
|
||||
if input.Name == nil || len(*input.Name) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Name is required")
|
||||
}
|
||||
|
||||
|
@ -121,7 +133,7 @@ func main() {
|
|||
return c.Status(fiber.StatusBadRequest).SendString("Date is required")
|
||||
}
|
||||
|
||||
item, err := CreateScheduleItem(db, *input.Name, input.Description, *input.Duration, *input.Date)
|
||||
item, err := CreateScheduleItem(db, *input.Name, input.Description, *input.Duration, *input.Date, input.Tags)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("failed to create schedule item: %v", err)
|
||||
|
@ -144,7 +156,7 @@ func main() {
|
|||
log.Printf("failed to find schedule items: %v", err)
|
||||
return c.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
|
||||
|
||||
schedule := map[string][]ScheduleItem{}
|
||||
|
||||
for _, date := range dateRange(fromDate, toDate) {
|
||||
|
@ -197,11 +209,83 @@ func main() {
|
|||
|
||||
return c.SendStatus(http.StatusOK)
|
||||
})
|
||||
|
||||
app.Use(filesystem.New(filesystem.Config{
|
||||
Root: http.FS(f),
|
||||
PathPrefix: "public",
|
||||
}))
|
||||
|
||||
app.Get("/api/tags", func(c *fiber.Ctx) error {
|
||||
tags, err := GetScheduleTags(db)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("failed to find schedule tags: %v", err)
|
||||
return c.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return c.JSON(tags)
|
||||
})
|
||||
|
||||
app.Post("/api/tags", func(c *fiber.Ctx) error {
|
||||
input := new(NewScheduleTag)
|
||||
|
||||
if err := c.BodyParser(input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if input.Name == nil || len(*input.Name) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Name is required")
|
||||
}
|
||||
|
||||
tag, err := CreateScheduleTag(db, *input.Name)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("failed to create schedule tag: %v", err)
|
||||
return c.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return c.JSON(tag)
|
||||
})
|
||||
|
||||
app.Get("/api/tags/:id<int>", func(c *fiber.Ctx) error {
|
||||
id, err := c.ParamsInt("id")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid id")
|
||||
}
|
||||
|
||||
tag, err := GetScheduleTag(db, id)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("failed to find schedule tag: %v", err)
|
||||
return c.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if tag == nil {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
return c.JSON(*tag)
|
||||
})
|
||||
|
||||
app.Delete("/api/tags/:id<int>", func(c *fiber.Ctx) error {
|
||||
id, err := c.ParamsInt("id")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid id")
|
||||
}
|
||||
|
||||
ok, err := DeleteScheduleTag(db, id)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("failed to delete schedule item: %v", err)
|
||||
return c.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
return c.SendStatus(http.StatusOK)
|
||||
})
|
||||
|
||||
app.Use(filesystem.New(filesystem.Config{
|
||||
Root: http.FS(f),
|
||||
PathPrefix: "public",
|
||||
}))
|
||||
|
||||
log.Fatal(app.Listen(":3000"))
|
||||
}
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Schedule</title>
|
||||
<script type="module" crossorigin src="/assets/index-b0266e6c.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-7823ff9f.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue