DEVELOPMENT ENVIRONMENT

~liljamo/tixe

d450fba0e13436d25f1206a698b4dc3a65c26a94 — Jonni Liljamo 1 year, 2 months ago 7980555
feat: tag management thingy
A api/tags.go => api/tags.go +67 -0
@@ 0,0 1,67 @@
package api

import (
	"context"
	"log"
	"net/http"
	"tixe/db"
	"tixe/types"

	"github.com/gin-contrib/sessions"
	"github.com/gin-gonic/gin"
	"github.com/oklog/ulid/v2"
)

type postTagsNew struct {
	Tag string `form:"tag"`
}

func TagsNew(c *gin.Context) {
	data := &postTagsNew{}
	if err := c.Bind(data); err != nil {
		log.Printf("[tixe/api] ERROR: Could not bind new tag data: %v", err)
		c.String(http.StatusBadRequest, "Could not bind new tag data")
		return;
	}

	session := sessions.Default(c)
	user := session.Get("user").(types.User)

	tagId := ulid.Make().String()

	_, err := db.PgPool.Exec(context.Background(),
		"INSERT INTO tags(id, user_id, tag) VALUES($1, $2, $3)", tagId, user.Id, data.Tag)
	if err != nil {
		log.Printf("[tixe/api] ERROR: Could not create new tag entry in database: %v", err)
		c.String(http.StatusInternalServerError, "Could not create new tag entry in database!")
		return;
	}

	c.Redirect(http.StatusFound, "/tags")
}

type postTagsDelete struct {
	Id string `form:"id"`
}

func TagsDelete(c *gin.Context) {
	data := &postTagsDelete{}
	if err := c.Bind(data); err != nil {
		log.Printf("[tixe/api] ERROR: Could not bind new tag data: %v", err)
		c.String(http.StatusBadRequest, "Could not bind new tag data")
		return;
	}

	session := sessions.Default(c)
	user := session.Get("user").(types.User)

	_, err := db.PgPool.Exec(context.Background(),
		"DELETE FROM tags WHERE id = $1 AND user_id = $2", data.Id, user.Id)
	if err != nil {
		log.Printf("[tixe/api] ERROR: Could not create new tag entry in database: %v", err)
		c.String(http.StatusInternalServerError, "Could not create new tag entry in database!")
		return;
	}

	c.Redirect(http.StatusFound, "/tags")
}

M db/migrations.go => db/migrations.go +6 -0
@@ 69,5 69,11 @@ func migrations() []string {
			display_name TEXT NOT NULL,
			oidc_subject TEXT
		)`),
		fmt.Sprintf(`CREATE TABLE tags (
			id CHAR(26) NOT NULL PRIMARY KEY,
			user_id CHAR(26) NOT NULL REFERENCES users(id),
			tag TEXT NOT NULL,
			UNIQUE (user_id, tag)
		)`),
	}
}

A handlers/tags.go => handlers/tags.go +62 -0
@@ 0,0 1,62 @@
package handlers

import (
	"context"
	"log"
	"net/http"
	"tixe/db"
	"tixe/template"
	"tixe/types"

	"github.com/gin-contrib/sessions"
	"github.com/gin-gonic/gin"
)

type tag struct {
	Id        string
	Tag       string
	TimesUsed uint
}

type tagsData struct {
	// NOTE: It's this way because it needs to be sorted alphabetically
	// Maybe there's some more efficient way to do this, but this is good enough
	// for now
	Tags []tag
}

func Tags(c *gin.Context) {
	session := sessions.Default(c)
	user := session.Get("user").(types.User)

	var tags []tag
	// Query tags which the user has defined
	// TODO: Query also the amount of times the tag is used, meaning join
	// with the links table too.
	rows, _ := db.PgPool.Query(context.Background(),
		`SELECT id, tag FROM tags
			WHERE user_id = $1`, user.Id)
	for rows.Next() {
		var id string
		var tagstr string
		err := rows.Scan(&id, &tagstr)
		if err != nil {
			// FIXME: user doesn't currently know if this happens (though it shouldn't (tm))
			// something something handle me better
			log.Printf("[tixe/handlers] ERROR: Failed to scan a row when querying for tags: %v", err)
			continue
		}
		// FIXME: There has to be a better way to do this, right?
		// Like, not appending to an array, but some other other data structure
		// completely, or somehow we should get the amount of rows first, so
		// we can set the array values, instead of doing this append...
		tags = append(tags, tag { Id: id, Tag: tagstr, TimesUsed: 0})
	}

	data := tagsData {
		Tags: tags,
	}

	html := template.TmplEngine.Render("tags.tmpl", map[string]interface{}{"title": "tags", "user": user, "data": data})
	c.Data(http.StatusOK, "text/html", html)
}

M static/styles.css => static/styles.css +119 -20
@@ 546,18 546,51 @@ video {
  z-index: 50;
}

.ml-2 {
  margin-left: 0.5rem;
}

.mr-8 {
  margin-right: 2rem;
}

.flex {
  display: flex;
}

.grid {
  display: grid;
}

.hidden {
  display: none;
}

.h-4 {
  height: 1rem;
}

.h-5 {
  height: 1.25rem;
}

.h-8 {
  height: 2rem;
}

.h-fit {
  height: -moz-fit-content;
  height: fit-content;
}

.w-4 {
  width: 1rem;
}

.w-5 {
  width: 1.25rem;
}

.w-fit {
  width: -moz-fit-content;
  width: fit-content;


@@ 593,6 626,10 @@ video {
  align-items: center;
}

.gap-1 {
  gap: 0.25rem;
}

.gap-2 {
  gap: 0.5rem;
}


@@ 648,14 685,14 @@ video {
  --tw-gradient-stops: var(--tw-gradient-from), #52525b var(--tw-gradient-via-position), var(--tw-gradient-to);
}

.to-rose-500 {
  --tw-gradient-to: #f43f5e var(--tw-gradient-to-position);
}

.to-amber-500 {
  --tw-gradient-to: #f59e0b var(--tw-gradient-to-position);
}

.to-rose-500 {
  --tw-gradient-to: #f43f5e var(--tw-gradient-to-position);
}

.bg-size-200 {
  background-size: 200% 200%;
}


@@ 673,6 710,14 @@ video {
  fill: #fef08a;
}

.stroke-emerald-600 {
  stroke: #059669;
}

.stroke-rose-600 {
  stroke: #e11d48;
}

.stroke-yellow-400 {
  stroke: #facc15;
}


@@ 689,6 734,16 @@ video {
  padding: 1rem;
}

.px-2 {
  padding-left: 0.5rem;
  padding-right: 0.5rem;
}

.py-1 {
  padding-top: 0.25rem;
  padding-bottom: 0.25rem;
}

.pr-4 {
  padding-right: 1rem;
}


@@ 712,10 767,6 @@ video {
  font-weight: 700;
}

.font-medium {
  font-weight: 500;
}

.text-blue-500 {
  --tw-text-opacity: 1;
  color: rgb(59 130 246 / var(--tw-text-opacity));


@@ 760,20 811,14 @@ video {
  transition-duration: 500ms;
}

@keyframes bounce {
  0%, 100% {
    transform: translateY(-25%);
    animation-timing-function: cubic-bezier(0.8,0,1,1);
  }

  50% {
    transform: none;
    animation-timing-function: cubic-bezier(0,0,0.2,1);
  }
.odd\:bg-slate-200:nth-child(odd) {
  --tw-bg-opacity: 1;
  background-color: rgb(226 232 240 / var(--tw-bg-opacity));
}

.hover\:animate-bounce:hover {
  animation: bounce 1s infinite;
.even\:bg-slate-100:nth-child(even) {
  --tw-bg-opacity: 1;
  background-color: rgb(241 245 249 / var(--tw-bg-opacity));
}

.hover\:bg-slate-200:hover {


@@ 798,6 843,56 @@ video {
  visibility: visible;
}

@keyframes fullrotate {
  0% {
    transform: rotate(0deg);
  }

  100% {
    transform: rotate(360deg);
  }
}

.group:hover .group-hover\:animate-\[fullrotate_500ms_ease-in-out\] {
  animation: fullrotate 500ms ease-in-out;
}

@keyframes lidflip {
  0% {
    transform: rotate(0deg);
  }

  50% {
    transform: rotate(180deg);
  }

  100% {
    transform: rotate(360deg);
  }
}

.group:hover .group-hover\:animate-lidflip {
  animation: lidflip 500ms linear;
}

@keyframes lidjump {
  0% {
    transform: translateY(0);
  }

  50% {
    transform: translateY(-75%);
  }

  100% {
    transform: translateY(0);
  }
}

.group:hover .group-hover\:animate-lidjump {
  animation: lidjump 500ms linear;
}

@keyframes starmove {
  0% {
    transform: translateX(-25%) translateY(-250%);


@@ 880,3 975,7 @@ video {
.group:hover .group-hover\:animate-wiggle {
  animation: wiggle 200ms ease-in-out;
}

.group:hover .group-hover\:stroke-emerald-700 {
  stroke: #047857;
}

M tailwind.config.js => tailwind.config.js +32 -0
@@ 63,11 63,43 @@ module.exports = {
						transform: 'translateX(-10%) translateY(75%)'
					},
				},
				lidjump: {
					'0%': {
						transform: 'translateY(0)',
	  				},
					'50%': {
		  				transform: 'translateY(-75%)',
	  				},
					'100%': {
		  				transform: 'translateY(0)',
	  				},
				},
				lidflip: {
					'0%': {
		  				transform: 'rotate(0deg)',
	  				},
					'50%': {
		  				transform: 'rotate(180deg)',
	  				},
					'100%': {
		  				transform: 'rotate(360deg)',
	  				},
				},
				fullrotate: {
					'0%': {
						transform: 'rotate(0deg)',
					},
					'100%': {
						transform: 'rotate(360deg)',
					},
				},
			},
			animation: {
				wiggle: 'wiggle 200ms ease-in-out',
				starspin: 'starspin 750ms linear',
				starmove: 'starmove 750ms linear',
				lidjump: 'lidjump 500ms linear',
				lidflip: 'lidflip 500ms linear',
			},
		},
	},

A template/templates/tags.tmpl => template/templates/tags.tmpl +55 -0
@@ 0,0 1,55 @@
{{ define "content" }}
<p class="bg-size-200 bg-pos-0 hover:bg-pos-100
	bg-gradient-to-br from-slate-800 via-zinc-600 to-amber-500
	bg-clip-text text-lg font-bold text-transparent
	transition-all duration-500"
>
	Tags
</p>
<form class="flex flex-col" action="/api/tags/new" method="POST">
	<p class="text-sm" for="display_name">New Tag</p>
	<div class="flex gap-2">
		<input class="w-full h-8 p-1 border-2 rounded-md placeholder-slate-800 focus:outline-none" type="text" placeholder="..." name="tag" id="tag"/>
		<button class="group w-fit h-8 p-1 border-2 rounded-md drop-shadow-md bg-slate-50 hover:bg-slate-200 transition-colors" type="submit">
			<svg class="absolute h-5 w-5 stroke-emerald-600 group-hover:stroke-emerald-700 transition-colors" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M15 12L12 12M12 12L9 12M12 12L12 9M12 12L12 15" stroke-width="1.5" stroke-linecap="round"/>
			</svg>
			<svg class="group-hover:animate-[fullrotate_500ms_ease-in-out] h-5 w-5 stroke-emerald-600 group-hover:stroke-emerald-700 transition-colors" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M7 3.33782C8.47087 2.48697 10.1786 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 10.1786 2.48697 8.47087 3.33782 7" stroke-width="1.5" stroke-linecap="round"/>
			</svg>
		</button>
	</div>
</form>
<div class="grid w-fit">
	<div class="flex items-center gap-4 text-sm ml-2 mr-8">
		<p class="w-max">Name</p>
		<p class="w-full">Uses</p>
	</div>
	{{ range $tag := .data.Tags }}
		<div class="flex items-center gap-1 py-1 px-2 rounded-md odd:bg-slate-200 even:bg-slate-100">
			<p class="w-max">{{ $tag.Tag }}</p>
			<div class="flex flex-row-reverse w-full">
				<p>{{ $tag.TimesUsed }}</p>
			</div>
			<form class="min-w-max" action="/api/tags/delete" method="POST">
				<input class="hidden" type="text" name="id" id="id" value="{{ $tag.Id }}"/>
				<button class="flex items-center" type="submit">
					<div class="group h-5 w-5">
						<div class="absolute group-hover:animate-lidjump">
							<svg class="group-hover:animate-lidflip h-fit w-5 stroke-rose-600" viewBox="0 0 24 12" fill="none" xmlns="http://www.w3.org/2000/svg">
								<path d="M20.5001 6H3.5" stroke-width="1.5" stroke-linecap="round" />
								<path d="M6.5 6C6.55588 6 6.58382 6 6.60915 5.99936C7.43259 5.97849 8.15902 5.45491 8.43922 4.68032C8.44784 4.65649 8.45667 4.62999 8.47434 4.57697L8.57143 4.28571C8.65431 4.03708 8.69575 3.91276 8.75071 3.8072C8.97001 3.38607 9.37574 3.09364 9.84461 3.01877C9.96213 3 10.0932 3 10.3553 3H13.6447C13.9068 3 14.0379 3 14.1554 3.01877C14.6243 3.09364 15.03 3.38607 15.2493 3.8072C15.3043 3.91276 15.3457 4.03708 15.4286 4.28571L15.5257 4.57697C15.5433 4.62992 15.5522 4.65651 15.5608 4.68032C15.841 5.45491 16.5674 5.97849 17.3909 5.99936C17.4162 6 17.4441 6 17.5 6" stroke-width="1.5" />
							</svg>
						</div>
						<svg class="h-5 w-5 stroke-rose-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
							<path d="M9.5 11L10 16" stroke-width="1.5" stroke-linecap="round" />
							<path d="M14.5 11L14 16" stroke-width="1.5" stroke-linecap="round" />
							<path d="M18.3735 15.3991C18.1965 18.054 18.108 19.3815 17.243 20.1907C16.378 21 15.0476 21 12.3868 21H11.6134C8.9526 21 7.6222 21 6.75719 20.1907C5.89218 19.3815 5.80368 18.054 5.62669 15.3991L5.16675 8.5M18.8334 8.5L18.6334 11.5" stroke-width="1.5" stroke-linecap="round" />
						</svg>
					</div>
				</button>
			</form>
		</div>
	{{ end }}
</div>
{{ end }}

M tixe.go => tixe.go +3 -0
@@ 64,12 64,15 @@ func setupRouter(auth *auth.Auth) *gin.Engine {
	{
		reqAuth.GET("/", root)

		reqAuth.GET("/tags", handlers.Tags)
		reqAuth.GET("/settings", handlers.Settings)

		apiRoute := reqAuth.Group("/api")
		{
			apiRoute.GET("/", api.Root)
			apiRoute.GET("/ping", ping)
			apiRoute.POST("/tags/new", api.TagsNew)
			apiRoute.POST("/tags/delete", api.TagsDelete)
			apiRoute.POST("/settings/user/display_name", api.UserUpdateDisplayName)
		}
	}