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)
}
}