From 7746581cf2c2201816d744bc5c3ae1d1e5dc1ede Mon Sep 17 00:00:00 2001 From: Jonni Liljamo Date: Thu, 28 Sep 2023 23:45:12 +0300 Subject: [PATCH] feat: link management! --- api/links.go | 127 +++++++++++++ db/migrations.go | 10 + handlers/index.go | 52 ++++++ handlers/link.go | 40 ++++ input.css | 17 ++ static/styles.css | 301 +++++++++++++++++++++++++++++-- tailwind.config.js | 79 +++++++- template/templates/index.tmpl | 54 +++++- template/templates/linkedit.tmpl | 50 +++++ tixe.go | 16 +- types/link.go | 13 ++ 11 files changed, 735 insertions(+), 24 deletions(-) create mode 100644 api/links.go create mode 100644 handlers/index.go create mode 100644 handlers/link.go create mode 100644 template/templates/linkedit.tmpl create mode 100644 types/link.go diff --git a/api/links.go b/api/links.go new file mode 100644 index 0000000..1d7c079 --- /dev/null +++ b/api/links.go @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2023 Jonni Liljamo + * + * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for + * more information. + */ +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 postLinksNew struct { + Visual string `form:"visual"` + Link string `form:"link"` +} + +func LinkNew(c *gin.Context) { + data := &postLinksNew{} + if err := c.Bind(data); err != nil { + log.Printf("[tixe/api] ERROR: Could not bind new link data: %v", err) + c.String(http.StatusBadRequest, "Could not bind new link data") + return; + } + + session := sessions.Default(c) + user := session.Get("user").(types.User) + + linkId := ulid.Make().String() + + _, err := db.PgPool.Exec(context.Background(), + "INSERT INTO links(id, user_id, visual, link) VALUES($1, $2, $3, $4)", + linkId, user.Id, data.Visual, data.Link) + if err != nil { + log.Printf("[tixe/api] ERROR: Could not create new link entry in database: %v", err) + c.String(http.StatusInternalServerError, "Could not create new link entry in database!") + return; + } + + c.Redirect(http.StatusFound, "/") +} + +func LinkDelete(c *gin.Context) { + session := sessions.Default(c) + user := session.Get("user").(types.User) + + linkId := c.Param("id") + + _, err := db.PgPool.Exec(context.Background(), + "DELETE FROM links WHERE id = $1 AND user_id = $2", linkId, user.Id) + if err != nil { + errStr := "Could not delete link entry from database" + log.Printf("[tixe/api] ERROR: %s: %v", errStr, err) + c.String(http.StatusInternalServerError, errStr) + return; + } + + c.Redirect(http.StatusFound, "/") +} + +type postVisual struct { + Visual string `form:"visual"` +} + +func LinkUpdateVisual(c *gin.Context) { + data := &postVisual{} + if err := c.Bind(data); err != nil { + errStr := "Could not bind link visual update data" + log.Printf("[tixe/api] ERROR: %s: %v", errStr, err) + c.String(http.StatusBadRequest, errStr) + return; + } + + session := sessions.Default(c) + user := session.Get("user").(types.User) + + linkId := c.Param("id") + + _, err := db.PgPool.Exec(context.Background(), + "UPDATE links SET visual = $1 WHERE id = $2 AND user_id = $3", data.Visual, linkId, user.Id) + if err != nil { + errStr := "Could not update link visual in database" + log.Printf("[tixe/api] ERROR: %s: %v", errStr, err) + c.String(http.StatusInternalServerError, errStr) + return; + } + + c.Redirect(http.StatusFound, "/link/" + linkId) +} + +type postLink struct { + Link string `form:"link"` +} + +func LinkUpdateLink(c *gin.Context) { + data := &postLink{} + if err := c.Bind(data); err != nil { + errStr := "Could not bind link 'link' update data" + log.Printf("[tixe/api] ERROR: %s: %v", errStr, err) + c.String(http.StatusBadRequest, errStr) + return; + } + + session := sessions.Default(c) + user := session.Get("user").(types.User) + + linkId := c.Param("id") + + _, err := db.PgPool.Exec(context.Background(), + "UPDATE links SET link = $1 WHERE id = $2 AND user_id = $3", data.Link, linkId, user.Id) + if err != nil { + errStr := "Could not update link 'link' in database" + log.Printf("[tixe/api] ERROR: %s: %v", errStr, err) + c.String(http.StatusInternalServerError, errStr) + return; + } + + c.Redirect(http.StatusFound, "/link/" + linkId) +} diff --git a/db/migrations.go b/db/migrations.go index d59ac2b..302ba3f 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -81,5 +81,15 @@ func migrations() []string { tag TEXT NOT NULL, UNIQUE (user_id, tag) )`), + fmt.Sprintf(`CREATE TABLE links ( + id CHAR(26) NOT NULL PRIMARY KEY, + user_id CHAR(26) NOT NULL REFERENCES users(id), + visual TEXT NOT NULL, + link TEXT NOT NULL + )`), + fmt.Sprintf(`CREATE TABLE linktags ( + link_id CHAR(26) NOT NULL REFERENCES links(id), + tag_id CHAR(26) NOT NULL REFERENCES tags(id) + )`), } } diff --git a/handlers/index.go b/handlers/index.go new file mode 100644 index 0000000..163bf8b --- /dev/null +++ b/handlers/index.go @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 Jonni Liljamo + * + * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for + * more information. + */ +package handlers + +import ( + "context" + "log" + "net/http" + "tixe/db" + "tixe/template" + "tixe/types" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type linksData struct { + Links []types.Link +} + +func Index(c *gin.Context) { + session := sessions.Default(c) + user := session.Get("user").(types.User) + + var links []types.Link + rows, _ := db.PgPool.Query(context.Background(), + `SELECT id, visual, link FROM links + WHERE user_id = $1 + ORDER BY id DESC`, user.Id) + for rows.Next() { + var id, visual, linkstr string + err := rows.Scan(&id, &visual, &linkstr) + if err != nil { + // FIXME: + log.Printf("[tixe/handlers] ERROR: Failed to scan a row when querying for links: %v", err) + continue + } + // FIXME: + links = append(links, types.Link { Id: id, Visual: visual, Link: linkstr }) + } + + data := linksData { + Links: links, + } + + html := template.TmplEngine.Render("index.tmpl", map[string]interface{}{"title": "tixë", "user": user, "data": data}) + c.Data(http.StatusOK, "text/html", html) +} diff --git a/handlers/link.go b/handlers/link.go new file mode 100644 index 0000000..31dddad --- /dev/null +++ b/handlers/link.go @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 Jonni Liljamo + * + * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for + * more information. + */ +package handlers + +import ( + "context" + "net/http" + "tixe/db" + "tixe/template" + "tixe/types" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +func LinkEdit(c *gin.Context) { + session := sessions.Default(c) + user := session.Get("user").(types.User) + + linkId := c.Param("id") + + var link types.Link + err := db.PgPool.QueryRow(context.Background(), + "SELECT visual, link FROM links WHERE id = $1 AND user_id = $2", + linkId, user.Id).Scan(&link.Visual, &link.Link) + if err != nil { + // something something show error page and abort i guess + c.Abort() + return + } + + link.Id = linkId + + html := template.TmplEngine.Render("linkedit.tmpl", map[string]interface{}{"title": link.Visual, "user": user, "data": link}) + c.Data(http.StatusOK, "text/html", html) +} diff --git a/input.css b/input.css index b5c61c9..8aa14f2 100644 --- a/input.css +++ b/input.css @@ -1,3 +1,20 @@ @tailwind base; @tailwind components; @tailwind utilities; + +.scribble { + stroke-dasharray: 24; + stroke-dashoffset: 24; +} + +@keyframes scribble { + 0% { + stroke-dashoffset: 24; + } + 50% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: 24; + } +} diff --git a/static/styles.css b/static/styles.css index a4f1c67..4b70067 100644 --- a/static/styles.css +++ b/static/styles.css @@ -534,6 +534,10 @@ video { --tw-backdrop-sepia: ; } +.pointer-events-none { + pointer-events: none; +} + .invisible { visibility: hidden; } @@ -542,10 +546,18 @@ video { position: absolute; } +.relative { + position: relative; +} + .z-50 { z-index: 50; } +.m-2 { + margin: 0.5rem; +} + .ml-2 { margin-left: 0.5rem; } @@ -554,6 +566,10 @@ video { margin-right: 2rem; } +.inline-block { + display: inline-block; +} + .flex { display: flex; } @@ -574,6 +590,10 @@ video { height: 1.25rem; } +.h-6 { + height: 1.5rem; +} + .h-8 { height: 2rem; } @@ -583,6 +603,10 @@ video { height: fit-content; } +.w-36 { + width: 9rem; +} + .w-4 { width: 1rem; } @@ -591,6 +615,10 @@ video { width: 1.25rem; } +.w-6 { + width: 1.5rem; +} + .w-fit { width: -moz-fit-content; width: fit-content; @@ -605,6 +633,10 @@ video { width: max-content; } +.min-w-\[12rem\] { + min-width: 12rem; +} + .min-w-max { min-width: -moz-max-content; min-width: max-content; @@ -614,6 +646,58 @@ video { max-width: 56rem; } +.shrink-0 { + flex-shrink: 0; +} + +.origin-bottom-left { + transform-origin: bottom left; +} + +.origin-top { + transform-origin: top; +} + +.-translate-x-2 { + --tw-translate-x: -0.5rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-x-\[-22\%\] { + --tw-translate-x: -22%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-y-0 { + --tw-translate-y: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-y-\[-18\%\] { + --tw-translate-y: -18%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.cursor-pointer { + cursor: pointer; +} + +.select-none { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.list-none { + list-style-type: none; +} + +.appearance-none { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + .flex-row-reverse { flex-direction: row-reverse; } @@ -622,10 +706,18 @@ video { flex-direction: column; } +.flex-wrap { + flex-wrap: wrap; +} + .items-center { align-items: center; } +.justify-center { + justify-content: center; +} + .gap-1 { gap: 0.25rem; } @@ -718,6 +810,14 @@ video { stroke: #e11d48; } +.stroke-slate-600 { + stroke: #475569; +} + +.stroke-slate-800 { + stroke: #1e293b; +} + .stroke-yellow-400 { stroke: #facc15; } @@ -744,6 +844,10 @@ video { padding-bottom: 0.25rem; } +.pl-1 { + padding-left: 0.25rem; +} + .pr-4 { padding-right: 1rem; } @@ -763,37 +867,37 @@ video { line-height: 1.75rem; } -.font-bold { - font-weight: 700; +.text-xs { + font-size: 0.75rem; + line-height: 1rem; } -.text-blue-500 { - --tw-text-opacity: 1; - color: rgb(59 130 246 / var(--tw-text-opacity)); +.font-bold { + font-weight: 700; } .text-transparent { color: transparent; } -.placeholder-slate-800::-moz-placeholder { +.placeholder-slate-400::-moz-placeholder { --tw-placeholder-opacity: 1; - color: rgb(30 41 59 / var(--tw-placeholder-opacity)); + color: rgb(148 163 184 / var(--tw-placeholder-opacity)); } -.placeholder-slate-800::placeholder { +.placeholder-slate-400::placeholder { --tw-placeholder-opacity: 1; - color: rgb(30 41 59 / var(--tw-placeholder-opacity)); + color: rgb(148 163 184 / var(--tw-placeholder-opacity)); } -.placeholder-slate-400::-moz-placeholder { +.placeholder-slate-800::-moz-placeholder { --tw-placeholder-opacity: 1; - color: rgb(148 163 184 / var(--tw-placeholder-opacity)); + color: rgb(30 41 59 / var(--tw-placeholder-opacity)); } -.placeholder-slate-400::placeholder { +.placeholder-slate-800::placeholder { --tw-placeholder-opacity: 1; - color: rgb(148 163 184 / var(--tw-placeholder-opacity)); + color: rgb(30 41 59 / var(--tw-placeholder-opacity)); } .opacity-0 { @@ -817,10 +921,33 @@ video { transition-duration: 150ms; } +.duration-300 { + transition-duration: 300ms; +} + .duration-500 { transition-duration: 500ms; } +.scribble { + stroke-dasharray: 24; + stroke-dashoffset: 24; +} + +@keyframes scribble { + 0% { + stroke-dashoffset: 24; + } + + 50% { + stroke-dashoffset: 0; + } + + 100% { + stroke-dashoffset: 24; + } +} + .odd\:bg-slate-200:nth-child(odd) { --tw-bg-opacity: 1; background-color: rgb(226 232 240 / var(--tw-bg-opacity)); @@ -831,6 +958,10 @@ video { background-color: rgb(241 245 249 / var(--tw-bg-opacity)); } +.hover\:cursor-pointer:hover { + cursor: pointer; +} + .hover\:bg-slate-200:hover { --tw-bg-opacity: 1; background-color: rgb(226 232 240 / var(--tw-bg-opacity)); @@ -849,6 +980,27 @@ video { outline-offset: 2px; } +.group\/summary[open] .group-open\/summary\:rotate-90 { + --tw-rotate: 90deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +@keyframes newlink { + 0% { + transform: scale(1, 0); + opacity: 0; + } + + 100% { + transform: scale(1, 1); + opacity: 1; + } +} + +.group\/summary[open] .group-open\/summary\:animate-\[newlink_300ms_ease-in-out\] { + animation: newlink 300ms ease-in-out; +} + .group:hover .group-hover\:visible { visibility: visible; } @@ -863,6 +1015,20 @@ video { } } +.group\/submit:hover .group-hover\/submit\:animate-\[fullrotate_500ms_ease-in-out\] { + animation: fullrotate 500ms ease-in-out; +} + +@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; } @@ -903,6 +1069,102 @@ video { animation: lidjump 500ms linear; } +@keyframes penmove { + 0% { + transform: translateX(-22%) translateY(-18%); + } + + 7.5% { + transform: translateX(-10%) translateY(-24%); + } + + 22.5% { + transform: translateX(10%) translateY(-4%); + } + + 30% { + transform: translateX(25%) translateY(-10%); + } + + 40% { + transform: translateX(50%) translateY(7%); + } + + 50% { + transform: translateX(50%) translateY(7%); + } + + 60% { + transform: translateX(150%) translateY(-93%); + } + + 70% { + transform: translateX(125%) translateY(-110%); + } + + 77.5% { + transform: translateX(110%) translateY(-104%); + } + + 92.5% { + transform: translateX(110%) translateY(-124%); + } + + 100% { + transform: translateX(78%) translateY(-118%); + } +} + +.group:hover .group-hover\:animate-penmove { + animation: penmove 1000ms linear; +} + +@keyframes penrotate { + 0% { + transform: rotate(0deg); + } + + 7.5% { + transform: rotate(5deg); + } + + 22.5% { + transform: rotate(10deg); + } + + 30% { + transform: rotate(7deg); + } + + 40% { + transform: rotate(0deg); + } + + 50% { + transform: rotate(-10deg); + } + + 60% { + transform: rotate(180deg); + } + + 90% { + transform: rotate(180deg); + } + + 100% { + transform: rotate(180deg); + } +} + +.group:hover .group-hover\:animate-penrotate { + animation: penrotate 1000ms linear; +} + +.group:hover .group-hover\:animate-scribble { + animation: scribble 1000ms linear forwards; +} + @keyframes starmove { 0% { transform: translateX(-25%) translateY(-250%); @@ -986,6 +1248,19 @@ video { animation: wiggle 200ms ease-in-out; } +.group\/submit:hover .group-hover\/submit\:stroke-emerald-700 { + stroke: #047857; +} + .group:hover .group-hover\:stroke-emerald-700 { stroke: #047857; } + +.peer:checked ~ .peer-checked\:block { + display: block; +} + +.peer:checked ~ .peer-checked\:rotate-90 { + --tw-rotate: 90deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} diff --git a/tailwind.config.js b/tailwind.config.js index 3c21bc5..61d10ee 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -92,7 +92,81 @@ module.exports = { '100%': { transform: 'rotate(360deg)', }, - }, + }, + newlink: { + '0%': { + transform: 'scale(1, 0)', + opacity: '0', + }, + '100%': { + transform: 'scale(1, 1)', + opacity: '1', + }, + }, + penrotate: { + '0%': { + transform: 'rotate(0deg)', + }, + '7.5%': { + transform: 'rotate(5deg)', + }, + '22.5%': { + transform: 'rotate(10deg)', + }, + '30%': { + transform: 'rotate(7deg)', + }, + '40%': { + transform: 'rotate(0deg)', + }, + '50%': { + transform: 'rotate(-10deg)', + }, + '60%': { + transform: 'rotate(180deg)', + }, + '90%': { + transform: 'rotate(180deg)', + }, + '100%': { + transform: 'rotate(180deg)', + }, + }, + penmove: { + '0%': { + transform: 'translateX(-22%) translateY(-18%)', + }, + '7.5%': { + transform: 'translateX(-10%) translateY(-24%)', + }, + '22.5%': { + transform: 'translateX(10%) translateY(-4%)', + }, + '30%': { + transform: 'translateX(25%) translateY(-10%)', + }, + '40%': { + transform: 'translateX(50%) translateY(7%)', + }, + '50%': { + transform: 'translateX(50%) translateY(7%)', + }, + '60%': { + transform: 'translateX(150%) translateY(-93%)', + }, + '70%': { + transform: 'translateX(125%) translateY(-110%)', + }, + '77.5%': { + transform: 'translateX(110%) translateY(-104%)', + }, + '92.5%': { + transform: 'translateX(110%) translateY(-124%)', + }, + '100%': { + transform: 'translateX(78%) translateY(-118%)', + }, + }, }, animation: { wiggle: 'wiggle 200ms ease-in-out', @@ -100,6 +174,9 @@ module.exports = { starmove: 'starmove 750ms linear', lidjump: 'lidjump 500ms linear', lidflip: 'lidflip 500ms linear', + scribble: 'scribble 1000ms linear forwards', + penrotate: 'penrotate 1000ms linear', + penmove: 'penmove 1000ms linear', }, }, }, diff --git a/template/templates/index.tmpl b/template/templates/index.tmpl index aacef05..4416082 100644 --- a/template/templates/index.tmpl +++ b/template/templates/index.tmpl @@ -1,3 +1,55 @@ {{ define "content" }} -

{{ .title }}

+
+ +
+ + + + +
+
New link
+
+ +
+
+ {{ range $link := .data.Links }} +
+ {{ $link.Visual }} +

Insert description here

+ +
+ {{ end }} +
{{ end }} diff --git a/template/templates/linkedit.tmpl b/template/templates/linkedit.tmpl new file mode 100644 index 0000000..8eff06b --- /dev/null +++ b/template/templates/linkedit.tmpl @@ -0,0 +1,50 @@ +{{ define "content" }} +
+ + +
+
+ +
+
+ + + + +
+ + + + + +
+ +
+
+ +
+

Visual

+
+ + +
+
+
+

Link

+
+ + +
+
+
+{{ end }} diff --git a/tixe.go b/tixe.go index 5663886..18236af 100644 --- a/tixe.go +++ b/tixe.go @@ -28,14 +28,6 @@ func ping(c *gin.Context) { c.String(http.StatusOK, "pong") } -func root(c *gin.Context) { - session := sessions.Default(c) - user := session.Get("user") - - html := template.TmplEngine.Render("index.tmpl", map[string]interface{}{"title": "tixë", "user": user}) - c.Data(http.StatusOK, "text/html", html) -} - func handleNoRoute(c *gin.Context) { session := sessions.Default(c) user := session.Get("user") @@ -68,15 +60,21 @@ func setupRouter(auth *auth.Auth) *gin.Engine { reqAuth := r.Group("/") reqAuth.Use(middlewares.IsAuthenticated) { - reqAuth.GET("/", root) + reqAuth.GET("/", handlers.Index) reqAuth.GET("/tags", handlers.Tags) reqAuth.GET("/settings", handlers.Settings) + reqAuth.GET("/link/:id", handlers.LinkEdit) + apiRoute := reqAuth.Group("/api") { apiRoute.GET("/", api.Root) apiRoute.GET("/ping", ping) + apiRoute.POST("/link/new", api.LinkNew) + apiRoute.POST("/link/:id/delete", api.LinkDelete) + apiRoute.POST("/link/:id/visual", api.LinkUpdateVisual) + apiRoute.POST("/link/:id/link", api.LinkUpdateLink) apiRoute.POST("/tags/new", api.TagsNew) apiRoute.POST("/tags/delete", api.TagsDelete) apiRoute.POST("/settings/user/display_name", api.UserUpdateDisplayName) diff --git a/types/link.go b/types/link.go new file mode 100644 index 0000000..e64e62f --- /dev/null +++ b/types/link.go @@ -0,0 +1,13 @@ +/* + * Copyright (C) 2023 Jonni Liljamo + * + * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for + * more information. + */ +package types + +type Link struct { + Id string + Visual string + Link string +} -- 2.44.1