DEVELOPMENT ENVIRONMENT

~liljamo/tixe

7746581cf2c2201816d744bc5c3ae1d1e5dc1ede — Jonni Liljamo 11 months ago 2627bf3
feat: link management!
A api/links.go => api/links.go +127 -0
@@ 0,0 1,127 @@
/*
 * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
 *
 * 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)
}

M db/migrations.go => db/migrations.go +10 -0
@@ 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)
		)`),
	}
}

A handlers/index.go => handlers/index.go +52 -0
@@ 0,0 1,52 @@
/*
 * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
 *
 * 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)
}

A handlers/link.go => handlers/link.go +40 -0
@@ 0,0 1,40 @@
/*
 * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
 *
 * 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)
}

M input.css => input.css +17 -0
@@ 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;
  }
}

M static/styles.css => static/styles.css +288 -13
@@ 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));
}

M tailwind.config.js => tailwind.config.js +78 -1
@@ 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',
			},
		},
	},

M template/templates/index.tmpl => template/templates/index.tmpl +53 -1
@@ 1,3 1,55 @@
{{ define "content" }}
<h1 class="text-blue-500">{{ .title }}</h1>
<details class="group/summary w-fit p-1 border-2 rounded-md">
	<summary class="list-none flex items-center cursor-pointer">
		<div>
			<svg class="group-open/summary:rotate-90 transition-all duration-300 w-5 h-5 stroke-slate-800" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M11 19L17 12L15.5 10.25M11 5L13 7.33333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
				<path d="M7 5L13 12L11.5 13.75M7 19L9 16.6667" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
			</svg>
		</div>
		<div class="select-none">New link</div>
	</summary>
	<form class="flex flex-col p-1 origin-top group-open/summary:animate-[newlink_300ms_ease-in-out]" action="/api/link/new" method="POST">
		<p class="text-sm" for="visual">Visual</p>
		<div class="flex gap-2">
			<input class="w-full h-8 p-1 border-2 rounded-md placeholder-slate-400 focus:outline-none" type="text" placeholder="..." name="visual" id="visual"/>
		</div>
		<p class="text-sm" for="link">Link</p>
		<div class="flex gap-2">
			<input class="w-full h-8 p-1 border-2 rounded-md placeholder-slate-400 focus:outline-none" type="text" placeholder="..." name="link" id="link"/>
			<button class="group/submit 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/submit: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/submit:animate-[fullrotate_500ms_ease-in-out] h-5 w-5 stroke-emerald-600 group-hover/submit: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>
</details>
<div class="flex flex-wrap justify-center gap-1">
	{{ range $link := .data.Links }}
		<div class="m-2 p-2 border-2 rounded-md min-w-[12rem] inline-block">
			<a class="hover:underline" href="{{ $link.Link }}" target="blank">{{ $link.Visual }}</a>
			<p class="text-xs">Insert description here</p>
			<div class="flex flex-row-reverse">
				<a href="/link/{{ $link.Id }}">
					<div class="group h-5 w-5">
						<div class="pointer-events-none absolute translate-x-[-22%] translate-y-[-18%] group-hover:animate-penmove">
							<svg class="h-5 w-5 stroke-slate-600 origin-bottom-left group-hover:animate-penrotate" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
								<path d="M14.3601 4.07866L15.2869 3.15178C16.8226 1.61607 19.3125 1.61607 20.8482 3.15178C22.3839 4.68748 22.3839 7.17735 20.8482 8.71306L19.9213 9.63993M14.3601 4.07866C14.3601 4.07866 14.4759 6.04828 16.2138 7.78618C17.9517 9.52407 19.9213 9.63993 19.9213 9.63993M14.3601 4.07866L12 6.43872M19.9213 9.63993L14.6607 14.9006L11.5613 18L11.4001 18.1612C10.8229 18.7383 10.5344 19.0269 10.2162 19.2751C9.84082 19.5679 9.43469 19.8189 9.00498 20.0237C8.6407 20.1973 8.25352 20.3263 7.47918 20.5844L4.19792 21.6782M4.19792 21.6782L3.39584 21.9456C3.01478 22.0726 2.59466 21.9734 2.31063 21.6894C2.0266 21.4053 1.92743 20.9852 2.05445 20.6042L2.32181 19.8021M4.19792 21.6782L2.32181 19.8021M2.32181 19.8021L3.41556 16.5208C3.67368 15.7465 3.80273 15.3593 3.97634 14.995C4.18114 14.5653 4.43213 14.1592 4.7249 13.7838C4.97308 13.4656 5.26166 13.1771 5.83882 12.5999L8.5 9.93872" stroke-width="1.5" stroke-linecap="round" />
							</svg>
						</div>
						<div class="absolute">
							<svg class="-translate-x-2 translate-y-0 h-6 w-6 stroke-slate-600 scribble group-hover:animate-scribble" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
								<path d="M5 14.8159L6.29064 13.4917C6.9621 12.8028 7.9741 12.8423 8.60499 13.5821L11.7658 17.2884C12.2722 17.8822 13.0693 17.9632 13.6552 17.4804L13.875 17.2993C14.7181 16.6045 15.8588 16.685 16.6248 17.4933L19 19.5" stroke-width="1.5" stroke-linecap="round" />
							</svg>
						</div>
					</div>
				</a>
			</div>
		</div>
	{{ end }}
</div>
{{ end }}

A template/templates/linkedit.tmpl => template/templates/linkedit.tmpl +50 -0
@@ 0,0 1,50 @@
{{ define "content" }}
<div class="flex flex-col gap-2">
	<div class="flex w-full justify-center">
		<a class="text-lg hover:underline" href={{ .data.Link }} target="blank">{{ .data.Visual }}</a>
	</div>

	<div class="flex w-36">
		<div class="h-6 group flex items-center">
			<input class="peer h-5 w-5 relative appearance-none shrink-0 hover:cursor-pointer" type="checkbox"/>
			<div class="absolute pointer-events-none h-5 w-5">
				<div class="absolute group-hover:animate-lidjump peer-checked:rotate-90">
					<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>
			<div class="pl-1 hidden peer-checked:block">
				<form action="/api/link/{{ .data.Id }}/delete" method="POST">
					<button class="hover:underline" type="submit">Confirm</button>
				</form>
			</div>
		</div>
	</div>

	<form class="flex flex-col" action="/api/link/{{ .data.Id }}/visual" method="POST">
		<p class="text-sm" for="visual">Visual</p>
		<div class="flex gap-2">
			<input class="w-full p-1 border-2 rounded-md placeholder-slate-800 focus:outline-none" type="text" value="{{ .data.Visual }}" name="visual" id="visual"/>
			<button class="w-fit p-1 border-2 rounded-md drop-shadow-md bg-slate-50 hover:bg-slate-200 transition-colors" type="submit">
				Update
			</button>
		</div>
	</form>
	<form class="flex flex-col" action="/api/link/{{ .data.Id }}/link" method="POST">
		<p class="text-sm" for="link">Link</p>
		<div class="flex gap-2">
			<input class="w-full p-1 border-2 rounded-md placeholder-slate-800 focus:outline-none" type="text" value="{{ .data.Link }}" name="link" id="link"/>
			<button class="w-fit p-1 border-2 rounded-md drop-shadow-md bg-slate-50 hover:bg-slate-200 transition-colors" type="submit">
				Update
			</button>
		</div>
	</form>
</div>
{{ end }}

M tixe.go => tixe.go +7 -9
@@ 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)

A types/link.go => types/link.go +13 -0
@@ 0,0 1,13 @@
/*
 * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
 *
 * 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
}