kennel (#13)
continuous-integration/drone/push Build is running Details

Reviewed-on: #13
Co-authored-by: Elizabeth Hunt <elizabeth@simponic.xyz>
Co-committed-by: Elizabeth Hunt <elizabeth@simponic.xyz>
This commit is contained in:
Elizabeth Hunt 2024-08-17 18:29:33 -04:00 committed by simponic
parent 0b8883c236
commit b1775c4408
12 changed files with 530 additions and 5 deletions

View File

@ -4,5 +4,6 @@ import "io"
type FilesAdapter interface { type FilesAdapter interface {
CreateFile(path string, content io.Reader) (string, error) CreateFile(path string, content io.Reader) (string, error)
FileExists(path string) bool
DeleteFile(path string) error DeleteFile(path string) error
} }

View File

@ -35,3 +35,8 @@ func (f *FilesystemAdapter) CreateFile(path string, content io.Reader) (string,
func (f *FilesystemAdapter) DeleteFile(path string) error { func (f *FilesystemAdapter) DeleteFile(path string) error {
return os.Remove(f.BasePath + path) return os.Remove(f.BasePath + path)
} }
func (f *FilesystemAdapter) FileExists(path string) bool {
_, err := os.Stat(f.BasePath + path)
return err == nil
}

238
api/kennel/kennel.go Normal file
View File

@ -0,0 +1,238 @@
package kennel
import (
"encoding/json"
"log"
"net/http"
"strings"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/files"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/database"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/utils"
)
const MaxCatSize = 1024 * 100 // 60KB
const CatsPath = "cats/"
const CatsPrefix = "/uploads/cats/"
const DefaultCatSpritesheet = "/static/img/cat_spritesheets/default.gif"
const MaxUserCats = 15
func ListUserCatsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
userID := context.User.ID
cats, err := database.GetUserKennelCats(context.DBConn, userID)
if err != nil {
log.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
(*context.TemplateData)["Cats"] = cats
return success(context, req, resp)
}
}
func CreateCatContinuation(fileAdapter files.FilesAdapter, maxUserCats int, maxCatSize int, catsPath string, catsPrefix string, defaultCatSpritesheet string) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
formErrors := types.BannerMessages{
Messages: []string{},
}
numCats, err := database.CountUserKennelCats(context.DBConn, context.User.ID)
if err != nil {
log.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
if numCats >= maxUserCats {
formErrors.Messages = append(formErrors.Messages, "max cats reached for user")
}
err = req.ParseMultipartForm(int64(maxCatSize))
if err != nil {
formErrors.Messages = append(formErrors.Messages, "cat spritesheet too large")
}
catID := utils.RandomId()
spritesheetPath := catsPrefix + catID
if len(formErrors.Messages) == 0 {
file, _, err := req.FormFile("spritesheet")
if file != nil && err != nil {
formErrors.Messages = append(formErrors.Messages, "error uploading spritesheet")
} else if file != nil {
defer file.Close()
reader := http.MaxBytesReader(resp, file, int64(maxCatSize))
defer reader.Close()
_, err = fileAdapter.CreateFile(catsPath+catID, reader)
if err != nil {
log.Println(err)
formErrors.Messages = append(formErrors.Messages, "error saving spritesheet (is it too big?)")
}
} else if file == nil && err != nil {
spritesheetPath = defaultCatSpritesheet
}
}
link := req.FormValue("link")
description := req.FormValue("description")
name := req.FormValue("name")
cat := &database.KennelCat{
ID: catID,
UserID: context.User.ID,
Name: name,
Link: link,
Description: description,
Spritesheet: spritesheetPath,
}
formErrors.Messages = append(formErrors.Messages, validateCat(cat)...)
if len(formErrors.Messages) == 0 {
_, err := database.SaveKennelCat(context.DBConn, cat)
if err != nil {
log.Println(err)
formErrors.Messages = append(formErrors.Messages, "failed to save cat")
}
}
if len(formErrors.Messages) > 0 {
(*context.TemplateData)["Error"] = formErrors
(*context.TemplateData)["CatForm"] = cat
resp.WriteHeader(http.StatusBadRequest)
return failure(context, req, resp)
}
formSuccess := types.BannerMessages{
Messages: []string{"cat added."},
}
(*context.TemplateData)["Success"] = formSuccess
return success(context, req, resp)
}
}
}
func RemoveCatContinuation(fileAdapter files.FilesAdapter, catsPath string) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
catID := req.FormValue("id")
cat, err := database.GetKennelCat(context.DBConn, catID)
if err != nil {
log.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
if cat == nil || cat.UserID != context.User.ID {
resp.WriteHeader(http.StatusUnauthorized)
return failure(context, req, resp)
}
err = database.DeleteKennelCat(context.DBConn, catID)
if err != nil {
log.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
err = fileAdapter.DeleteFile(catsPath + catID)
if err != nil && fileAdapter.FileExists(catsPath+catID) {
log.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
return success(context, req, resp)
}
}
}
func RingContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
order := req.URL.Query().Get("order")
if order == "random" {
kennelCat, err := database.GetRandomKennelCat(context.DBConn)
if err != nil {
log.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
http.Redirect(resp, req, kennelCat.Link, http.StatusFound)
return success(context, req, resp)
}
id := req.URL.Query().Get("id")
if id == "" {
resp.WriteHeader(http.StatusBadRequest)
return failure(context, req, resp)
}
if order != "random" && order != "next" && order != "prev" {
kennelCat, err := database.GetKennelCat(context.DBConn, id)
if err != nil {
log.Println(err)
resp.WriteHeader(http.StatusNotFound)
return failure(context, req, resp)
}
http.Redirect(resp, req, kennelCat.Link, http.StatusFound)
return success(context, req, resp)
}
nextCat, err := database.GetNextKennelCat(context.DBConn, id, order == "next")
if err != nil {
log.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
http.Redirect(resp, req, nextCat.Link, http.StatusFound)
return success(context, req, resp)
}
}
func GetKennelContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
cats, err := database.GetKennel(context.DBConn)
if err != nil {
log.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
json, err := json.Marshal(cats)
if err != nil {
log.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
resp.Header().Set("Content-Type", "application/json")
resp.Write(json)
return success(context, req, resp)
}
}
func validateCat(cat *database.KennelCat) []string {
errors := []string{}
if cat.Name == "" {
errors = append(errors, "name is required")
}
if cat.Link == "" {
errors = append(errors, "link is required")
}
if !strings.HasPrefix(cat.Link, "http://") && !strings.HasPrefix(cat.Link, "https://") {
errors = append(errors, "link must be a valid URL")
}
if cat.Description == "" {
errors = append(errors, "description is required")
}
if len(cat.Description) > 100 {
errors = append(errors, "description must be less than 100 characters")
}
return errors
}

View File

@ -13,6 +13,7 @@ import (
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/dns" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/dns"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/guestbook" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/guestbook"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/hcaptcha" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/hcaptcha"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/kennel"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/keys" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/keys"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/profiles" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/profiles"
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/template" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/template"
@ -172,10 +173,38 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(hcaptcha.CaptchaVerificationContinuation, hcaptcha.CaptchaVerificationContinuation)(guestbook.SignGuestbookContinuation, FailurePassingContinuation)(guestbook.ListGuestbookContinuation, guestbook.ListGuestbookContinuation)(hcaptcha.CaptchaArgsContinuation, hcaptcha.CaptchaArgsContinuation)(template.TemplateContinuation("guestbook.html", true), template.TemplateContinuation("guestbook.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(hcaptcha.CaptchaVerificationContinuation, hcaptcha.CaptchaVerificationContinuation)(guestbook.SignGuestbookContinuation, FailurePassingContinuation)(guestbook.ListGuestbookContinuation, guestbook.ListGuestbookContinuation)(hcaptcha.CaptchaArgsContinuation, hcaptcha.CaptchaArgsContinuation)(template.TemplateContinuation("guestbook.html", true), template.TemplateContinuation("guestbook.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
}) })
mux.HandleFunc("GET /{name}", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /kennel/", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext() requestContext := makeRequestContext()
name := r.PathValue("name") LogRequestContinuation(requestContext, r, w)(kennel.GetKennelContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(IdContinuation, IdContinuation)(template.TemplateContinuation(name+".html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) })
mux.HandleFunc("GET /kennel/cat", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
LogRequestContinuation(requestContext, r, w)(kennel.RingContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
mux.HandleFunc("GET /kennel/cats", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(kennel.ListUserCatsContinuation, auth.GoLoginContinuation)(template.TemplateContinuation("kennel_cats.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
mux.HandleFunc("POST /kennel/cats", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
createCatContinuation := kennel.CreateCatContinuation(uploadAdapter, kennel.MaxUserCats, kennel.MaxCatSize, kennel.CatsPath, kennel.CatsPrefix, kennel.DefaultCatSpritesheet)
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(createCatContinuation, FailurePassingContinuation)(kennel.ListUserCatsContinuation, kennel.ListUserCatsContinuation)(template.TemplateContinuation("kennel_cats.html", true), template.TemplateContinuation("kennel_cats.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
mux.HandleFunc("POST /kennel/cats/delete", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
deleteCatContinuation := kennel.RemoveCatContinuation(uploadAdapter, kennel.CatsPath)
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(deleteCatContinuation, FailurePassingContinuation)(kennel.ListUserCatsContinuation, FailurePassingContinuation)(template.TemplateContinuation("kennel_cats.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
mux.HandleFunc("GET /{template}", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
templateFile := r.PathValue("template")
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(IdContinuation, IdContinuation)(template.TemplateContinuation(templateFile+".html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
}) })
return &http.Server{ return &http.Server{

159
database/kennel.go Normal file
View File

@ -0,0 +1,159 @@
package database
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
"log"
"time"
)
type KennelCat struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Link string `json:"link"`
Description string `json:"description"`
Spritesheet string `json:"spritesheet"`
CreatedAt time.Time `json:"created_at"`
}
type KennelState struct {
At time.Time `json:"at"`
EncodedState string `json:"state"`
}
func CountUserKennelCats(db *sql.DB, userID string) (int, error) {
log.Println("counting kennel cats for user", userID)
row := db.QueryRow("SELECT COUNT(*) FROM kennel_cat WHERE user_id = ?", userID)
var count int
err := row.Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
func GetUserKennelCats(db *sql.DB, userID string) ([]KennelCat, error) {
log.Println("getting kennel cats for user", userID)
rows, err := db.Query("SELECT * FROM kennel_cat WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var cats []KennelCat
for rows.Next() {
var cat KennelCat
err := rows.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt)
if err != nil {
return nil, err
}
cats = append(cats, cat)
}
return cats, nil
}
func SaveKennelCat(db *sql.DB, cat *KennelCat) (*KennelCat, error) {
log.Println("saving kennel cat", cat.ID)
if (cat.CreatedAt == time.Time{}) {
cat.CreatedAt = time.Now()
}
_, err := db.Exec("INSERT OR REPLACE INTO kennel_cat (id, user_id, name, link, description, spritesheet, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", cat.ID, cat.UserID, cat.Name, cat.Link, cat.Description, cat.Spritesheet, cat.CreatedAt)
if err != nil {
return nil, err
}
return cat, nil
}
func GetKennelCat(db *sql.DB, catID string) (*KennelCat, error) {
log.Println("getting kennel cat", catID)
row := db.QueryRow("SELECT * FROM kennel_cat WHERE id = ?", catID)
var cat KennelCat
err := row.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt)
if err != nil {
return nil, err
}
return &cat, nil
}
func DeleteKennelCat(db *sql.DB, catID string) error {
log.Println("deleting kennel cat", catID)
_, err := db.Exec("DELETE FROM kennel_cat WHERE id = ?", catID)
if err != nil {
return err
}
return nil
}
func GetRandomKennelCat(dbConn *sql.DB) (*KennelCat, error) {
log.Println("getting random kennel cat")
row := dbConn.QueryRow("SELECT * FROM kennel_cat ORDER BY RANDOM() LIMIT 1")
var cat KennelCat
err := row.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt)
if err != nil {
return nil, err
}
return &cat, nil
}
func GetNextKennelCat(dbConn *sql.DB, lastID string, next bool) (*KennelCat, error) {
log.Println("getting next kennel cat")
operation := ">"
sorted := "ASC"
if !next {
operation = "<"
sorted = "DESC"
}
row := dbConn.QueryRow("SELECT * FROM kennel_cat WHERE id "+operation+" ? ORDER BY id "+sorted+" LIMIT 1", lastID)
var cat KennelCat
err := row.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt)
if err != nil {
if next {
// loop "back" to the first in the ring
row = dbConn.QueryRow("SELECT * FROM kennel_cat ORDER BY id ASC LIMIT 1")
} else {
// loop "forward" to the first in the ring
row = dbConn.QueryRow("SELECT * FROM kennel_cat ORDER BY id DESC LIMIT 1")
}
err = row.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt)
}
if err != nil {
return nil, err
}
return &cat, nil
}
func GetKennel(dbConn *sql.DB) ([]KennelCat, error) {
log.Println("getting kennel")
rows, err := dbConn.Query("SELECT * FROM kennel_cat")
if err != nil {
return nil, err
}
defer rows.Close()
var cats []KennelCat
for rows.Next() {
var cat KennelCat
err := rows.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt)
if err != nil {
return nil, err
}
cats = append(cats, cat)
}
return cats, nil
}

View File

@ -162,6 +162,26 @@ func MigrateProfiles(dbConn *sql.DB) (*sql.DB, error) {
return dbConn, nil return dbConn, nil
} }
func MigrateKennel(dbConn *sql.DB) (*sql.DB, error) {
log.Println("migrating kennel tables")
_, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS kennel_cat (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
user_id INTEGER NOT NULL,
link TEXT NOT NULL,
description TEXT NOT NULL,
spritesheet TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);`)
if err != nil {
return dbConn, err
}
return dbConn, nil
}
func Migrate(dbConn *sql.DB) (*sql.DB, error) { func Migrate(dbConn *sql.DB) (*sql.DB, error) {
log.Println("migrating database") log.Println("migrating database")
@ -173,6 +193,7 @@ func Migrate(dbConn *sql.DB) (*sql.DB, error) {
MigrateDNSRecords, MigrateDNSRecords,
MigrateGuestBook, MigrateGuestBook,
MigrateProfiles, MigrateProfiles,
MigrateKennel,
} }
for _, migration := range migrations { for _, migration := range migrations {

View File

@ -7,6 +7,7 @@
--container-bg-light: #fff7f87a; --container-bg-light: #fff7f87a;
--border-color-light: #692fcc; --border-color-light: #692fcc;
--error-color-light: #a83254; --error-color-light: #a83254;
--tr-color-light: #8bcefa;
--background-color-dark: #333; --background-color-dark: #333;
--background-color-dark-2: #2c2c2c; --background-color-dark-2: #2c2c2c;
@ -16,6 +17,7 @@
--container-bg-dark: #424242ea; --container-bg-dark: #424242ea;
--border-color-dark: #956ade; --border-color-dark: #956ade;
--error-color-dark: #851736; --error-color-dark: #851736;
--tr-color-dark: #212a6a;
} }
[data-theme="DARK"] { [data-theme="DARK"] {
@ -27,6 +29,7 @@
--border-color: var(--border-color-dark); --border-color: var(--border-color-dark);
--error-color: var(--error-color-dark); --error-color: var(--error-color-dark);
--confirm-color: var(--confirm-color-dark); --confirm-color: var(--confirm-color-dark);
--tr-color: var(--tr-color-dark);
} }
[data-theme="LIGHT"] { [data-theme="LIGHT"] {
@ -38,6 +41,7 @@
--border-color: var(--border-color-light); --border-color: var(--border-color-light);
--error-color: var(--error-color-light); --error-color: var(--error-color-light);
--confirm-color: var(--confirm-color-light); --confirm-color: var(--confirm-color-light);
--tr-color: var(--tr-color-light);
} }
.error { .error {

View File

@ -26,6 +26,6 @@ tbody tr {
} }
tbody tr:hover { tbody tr:hover {
background-color: #ff47daa0; background-color: var(--tr-color);
color: #2a2a2a; color: #2a2a2a;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -35,6 +35,8 @@
<span> | </span> <span> | </span>
<a href="/keys">api keys.</a> <a href="/keys">api keys.</a>
<span> | </span> <span> | </span>
<a href="/kennel/cats">kennel.</a>
<span> | </span>
<a href="/profile">{{ .User.DisplayName }}.</a> <a href="/profile">{{ .User.DisplayName }}.</a>
<span> | </span> <span> | </span>
<a href="/logout">logout.</a> <a href="/logout">logout.</a>

View File

@ -37,7 +37,7 @@
<p>note that the name <em>must</em> be a subdomain of <em>{{ .User.Username }}</em></p> <p>note that the name <em>must</em> be a subdomain of <em>{{ .User.Username }}</em></p>
<hr> <hr>
<label for="type">type.</label> <label for="type">type.</label>
<input type="text" name="type" placeholder="CNAME" <input type="text" name="type"
{{ if not .RecordForm }} {{ if not .RecordForm }}
placeholder="CNAME" placeholder="CNAME"
{{ else }} {{ else }}

View File

@ -0,0 +1,66 @@
{{ define "content" }}
<table>
<tr>
<th>name.</th>
<th>link.</th>
<th>description.</th>
<th>spritesheet.</th>
<th>created at.</th>
<th>remove.</th>
</tr>
{{ if (eq (len .Cats) 0) }}
<tr>
<td colspan="6"><span class="blinky">no cats found</span></td>
</tr>
{{ end }}
{{ range $cat := .Cats }}
<tr>
<td>{{ $cat.Name }}</td>
<td><a href="{{ $cat.Link }}">{{ $cat.Link }}</a></td>
<td>{{ $cat.Description }}</td>
<td><a href="{{ $cat.Spritesheet }}"><img width="100" src="{{ $cat.Spritesheet }}"></a></td>
<td class="time">{{ $cat.CreatedAt }}</td>
<td>
<form method="POST" action="/kennel/cats/delete">
<input type="hidden" name="id" value="{{ $cat.ID }}" />
<input type="submit" value="remove." />
</form>
</td>
</tr>
{{ end }}
</table>
<br>
<form method="POST" action="/kennel/cats" class="form" enctype="multipart/form-data">
<h2>add cat.</h2>
<hr>
<label for="name">name.</label>
<input type="text" name="name" id="name"
{{ if not .CatForm }}
placeholder="wallace."
{{ else }}
value="{{ .CatForm.Name }}"
{{ end }}
/>
<label for="description">description.</label>
<input type="text" name="description" id="description"
{{ if not .CatForm }}
placeholder="a cat."
{{ else }}
value="{{ .CatForm.Description }}"
{{ end }}
/>
<label for="link">link.</label>
<input type="text" name="link" id="link"
{{ if not .CatForm }}
placeholder="https://hatecomputers.club"
{{ else }}
value="{{ .CatForm.Link }}"
{{ end }}/>
<label for="spritesheet" style="margin:0">spritesheet.</label>
<h6>if not specified, will use <a href="/static/img/cat_spritesheets/default.gif">the default</a>. check it out for the format we expect. max 50KB.</h6>
<input type="file" name="spritesheet" id="spritesheet" />
<input type="submit" value="mrow." />
</form>
{{ end }}