initial commit

This commit is contained in:
basil 2025-06-14 23:47:44 -04:00
commit d40b69f1f9
Signed by: basil
SSH key fingerprint: SHA256:y04xIFL/yqNaG9ae9Vl95vELtHfApGAIoOGLeVLP/fE
58 changed files with 7919 additions and 0 deletions

9
.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"useTabs": true,
"printWidth": 80,
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"plugins": ["prettier-plugin-organize-imports"]
}

5
.zed/settings.json Normal file
View file

@ -0,0 +1,5 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{}

9
Caddyfile Normal file
View file

@ -0,0 +1,9 @@
http://localhost:8000 {
handle /api/v1/* {
reverse_proxy http://localhost:8088
}
handle {
reverse_proxy http://localhost:5173
}
}

10
README.md Normal file
View file

@ -0,0 +1,10 @@
# house
a home task management system
## tech stack
- golang
- bamboo (wip web framework in `backend/router` & `backend/middleware`)
- sqlc
- tanstack router
- radix-ui themes, icons, & primitives

1
backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
tmp

31
backend/db/db.go Normal file
View file

@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

45
backend/db/models.go Normal file
View file

@ -0,0 +1,45 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"database/sql"
)
type Category struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
}
type Session struct {
Key []byte `json:"key"`
UserID string `json:"user_id"`
CreatedAt int64 `json:"created_at"`
}
type Task struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
Completed int64 `json:"completed"`
CreatedBy string `json:"created_by"`
AssignedTo string `json:"assigned_to"`
CreatedAt int64 `json:"created_at"`
Deadline sql.NullInt64 `json:"deadline"`
}
type TaskCategory struct {
TaskID int64 `json:"task_id"`
CategoryID int64 `json:"category_id"`
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Password []byte `json:"password"`
}

663
backend/db/query.sql.go Normal file
View file

@ -0,0 +1,663 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: query.sql
package db
import (
"context"
"database/sql"
"strings"
)
const addTaskCategory = `-- name: AddTaskCategory :one
INSERT INTO task_categories (task_id, category_id)
VALUES (?, ?)
RETURNING task_id, category_id
`
type AddTaskCategoryParams struct {
TaskID int64 `json:"task_id"`
CategoryID int64 `json:"category_id"`
}
func (q *Queries) AddTaskCategory(ctx context.Context, arg AddTaskCategoryParams) (TaskCategory, error) {
row := q.db.QueryRowContext(ctx, addTaskCategory, arg.TaskID, arg.CategoryID)
var i TaskCategory
err := row.Scan(&i.TaskID, &i.CategoryID)
return i, err
}
const allTasks = `-- name: AllTasks :many
SELECT id, name, description, completed, created_by, assigned_to, created_at, deadline FROM tasks
`
func (q *Queries) AllTasks(ctx context.Context) ([]Task, error) {
rows, err := q.db.QueryContext(ctx, allTasks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Completed,
&i.CreatedBy,
&i.AssignedTo,
&i.CreatedAt,
&i.Deadline,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const categoriesOf = `-- name: CategoriesOf :many
SELECT c.id, c.name, c.description, c.created_by, c.created_at FROM categories c
JOIN task_categories tc ON tc.category_id = c.id
WHERE tc.task_id IN (/*SLICE:ids*/?)
`
func (q *Queries) CategoriesOf(ctx context.Context, ids []int64) ([]Category, error) {
query := categoriesOf
var queryParams []interface{}
if len(ids) > 0 {
for _, v := range ids {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(ids))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Category
for rows.Next() {
var i Category
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedBy,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const completeTask = `-- name: CompleteTask :one
UPDATE tasks SET completed = TRUE WHERE id = ? RETURNING id, name, description, completed, created_by, assigned_to, created_at, deadline
`
func (q *Queries) CompleteTask(ctx context.Context, id int64) (Task, error) {
row := q.db.QueryRowContext(ctx, completeTask, id)
var i Task
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Completed,
&i.CreatedBy,
&i.AssignedTo,
&i.CreatedAt,
&i.Deadline,
)
return i, err
}
const createCategory = `-- name: CreateCategory :one
INSERT INTO categories (
name, description, created_by, created_at
) VALUES (?, ?, ?, ?)
RETURNING id, name, description, created_by, created_at
`
type CreateCategoryParams struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
}
func (q *Queries) CreateCategory(ctx context.Context, arg CreateCategoryParams) (Category, error) {
row := q.db.QueryRowContext(ctx, createCategory,
arg.Name,
arg.Description,
arg.CreatedBy,
arg.CreatedAt,
)
var i Category
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedBy,
&i.CreatedAt,
)
return i, err
}
const createSession = `-- name: CreateSession :one
INSERT INTO sessions (
key,
user_id,
created_at
) VALUES (?, ?, ?)
RETURNING "key", user_id, created_at
`
type CreateSessionParams struct {
Key []byte `json:"key"`
UserID string `json:"user_id"`
CreatedAt int64 `json:"created_at"`
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, createSession, arg.Key, arg.UserID, arg.CreatedAt)
var i Session
err := row.Scan(&i.Key, &i.UserID, &i.CreatedAt)
return i, err
}
const createTask = `-- name: CreateTask :one
INSERT INTO tasks (
name,
description,
created_by,
assigned_to,
created_at,
deadline,
completed
) VALUES (?, ?, ?, ?, ?, ?, FALSE)
RETURNING id, name, description, completed, created_by, assigned_to, created_at, deadline
`
type CreateTaskParams struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
CreatedBy string `json:"created_by"`
AssignedTo string `json:"assigned_to"`
CreatedAt int64 `json:"created_at"`
Deadline sql.NullInt64 `json:"deadline"`
}
func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
row := q.db.QueryRowContext(ctx, createTask,
arg.Name,
arg.Description,
arg.CreatedBy,
arg.AssignedTo,
arg.CreatedAt,
arg.Deadline,
)
var i Task
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Completed,
&i.CreatedBy,
&i.AssignedTo,
&i.CreatedAt,
&i.Deadline,
)
return i, err
}
const createUser = `-- name: CreateUser :one
INSERT INTO users (
id,
username
) VALUES (
?, ?
) RETURNING id, username
`
type CreateUserParams struct {
ID string `json:"id"`
Username string `json:"username"`
}
type CreateUserRow struct {
ID string `json:"id"`
Username string `json:"username"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username)
var i CreateUserRow
err := row.Scan(&i.ID, &i.Username)
return i, err
}
const deleteCategory = `-- name: DeleteCategory :one
DELETE FROM categories WHERE id = ? LIMIT 1 RETURNING id, name, description, created_by, created_at
`
func (q *Queries) DeleteCategory(ctx context.Context, id int64) (Category, error) {
row := q.db.QueryRowContext(ctx, deleteCategory, id)
var i Category
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedBy,
&i.CreatedAt,
)
return i, err
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM sessions WHERE key = ? LIMIT 1 RETURNING "key", user_id, created_at
`
func (q *Queries) DeleteSession(ctx context.Context, key []byte) error {
_, err := q.db.ExecContext(ctx, deleteSession, key)
return err
}
const deleteTask = `-- name: DeleteTask :exec
DELETE FROM tasks WHERE id = ? LIMIT 1 RETURNING id, name, description, completed, created_by, assigned_to, created_at, deadline
`
func (q *Queries) DeleteTask(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteTask, id)
return err
}
const getSession = `-- name: GetSession :one
SELECT "key", user_id, created_at FROM sessions WHERE key = ? LIMIT 1
`
func (q *Queries) GetSession(ctx context.Context, key []byte) (Session, error) {
row := q.db.QueryRowContext(ctx, getSession, key)
var i Session
err := row.Scan(&i.Key, &i.UserID, &i.CreatedAt)
return i, err
}
const getUser = `-- name: GetUser :one
SELECT id, username FROM users WHERE id = ? LIMIT 1
`
type GetUserRow struct {
ID string `json:"id"`
Username string `json:"username"`
}
func (q *Queries) GetUser(ctx context.Context, id string) (GetUserRow, error) {
row := q.db.QueryRowContext(ctx, getUser, id)
var i GetUserRow
err := row.Scan(&i.ID, &i.Username)
return i, err
}
const getUserPassword = `-- name: GetUserPassword :one
SELECT id, password FROM users WHERE username = ? LIMIT 1
`
type GetUserPasswordRow struct {
ID string `json:"id"`
Password []byte `json:"password"`
}
func (q *Queries) GetUserPassword(ctx context.Context, username string) (GetUserPasswordRow, error) {
row := q.db.QueryRowContext(ctx, getUserPassword, username)
var i GetUserPasswordRow
err := row.Scan(&i.ID, &i.Password)
return i, err
}
const getUsers = `-- name: GetUsers :many
SELECT id, username, password FROM users ORDER BY name
`
func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
rows, err := q.db.QueryContext(ctx, getUsers)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(&i.ID, &i.Username, &i.Password); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCategories = `-- name: ListCategories :many
SELECT id, name, description, created_by, created_at FROM categories
`
func (q *Queries) ListCategories(ctx context.Context) ([]Category, error) {
rows, err := q.db.QueryContext(ctx, listCategories)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Category
for rows.Next() {
var i Category
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedBy,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const taskByID = `-- name: TaskByID :one
SELECT id, name, description, completed, created_by, assigned_to, created_at, deadline FROM tasks WHERE id = ? LIMIT 1
`
func (q *Queries) TaskByID(ctx context.Context, id int64) (Task, error) {
row := q.db.QueryRowContext(ctx, taskByID, id)
var i Task
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Completed,
&i.CreatedBy,
&i.AssignedTo,
&i.CreatedAt,
&i.Deadline,
)
return i, err
}
const taskCategoriesOf = `-- name: TaskCategoriesOf :many
SELECT tc.task_id, tc.category_id FROM task_categories tc
JOIN tasks t ON tc.task_id = t.id
WHERE tc.task_id IN (/*SLICE:ids*/?)
`
func (q *Queries) TaskCategoriesOf(ctx context.Context, ids []int64) ([]TaskCategory, error) {
query := taskCategoriesOf
var queryParams []interface{}
if len(ids) > 0 {
for _, v := range ids {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(ids))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TaskCategory
for rows.Next() {
var i TaskCategory
if err := rows.Scan(&i.TaskID, &i.CategoryID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const tasksForUser = `-- name: TasksForUser :many
SELECT id, name, description, completed, created_by, assigned_to, created_at, deadline FROM tasks WHERE assigned_to = ?
`
func (q *Queries) TasksForUser(ctx context.Context, assignedTo string) ([]Task, error) {
rows, err := q.db.QueryContext(ctx, tasksForUser, assignedTo)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Completed,
&i.CreatedBy,
&i.AssignedTo,
&i.CreatedAt,
&i.Deadline,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateCategory = `-- name: UpdateCategory :one
UPDATE categories SET
name = ?,
description = ?
WHERE id = ?
RETURNING id, name, description, created_by, created_at
`
type UpdateCategoryParams struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) (Category, error) {
row := q.db.QueryRowContext(ctx, updateCategory, arg.Name, arg.Description, arg.ID)
var i Category
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedBy,
&i.CreatedAt,
)
return i, err
}
const updateTask = `-- name: UpdateTask :one
UPDATE tasks SET
name = ?,
description = ?
WHERE id = ?
RETURNING id, name, description, completed, created_by, assigned_to, created_at, deadline
`
type UpdateTaskParams struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) {
row := q.db.QueryRowContext(ctx, updateTask, arg.Name, arg.Description, arg.ID)
var i Task
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Completed,
&i.CreatedBy,
&i.AssignedTo,
&i.CreatedAt,
&i.Deadline,
)
return i, err
}
const updateUser = `-- name: UpdateUser :one
UPDATE users
set username = ?
WHERE id = ?
RETURNING id, username
`
type UpdateUserParams struct {
Username string `json:"username"`
ID string `json:"id"`
}
type UpdateUserRow struct {
ID string `json:"id"`
Username string `json:"username"`
}
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) {
row := q.db.QueryRowContext(ctx, updateUser, arg.Username, arg.ID)
var i UpdateUserRow
err := row.Scan(&i.ID, &i.Username)
return i, err
}
const updateUserPassword = `-- name: UpdateUserPassword :one
UPDATE users
set password = ?
WHERE id = ?
RETURNING id, username
`
type UpdateUserPasswordParams struct {
Password []byte `json:"password"`
ID string `json:"id"`
}
type UpdateUserPasswordRow struct {
ID string `json:"id"`
Username string `json:"username"`
}
func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) (UpdateUserPasswordRow, error) {
row := q.db.QueryRowContext(ctx, updateUserPassword, arg.Password, arg.ID)
var i UpdateUserPasswordRow
err := row.Scan(&i.ID, &i.Username)
return i, err
}
const userCategories = `-- name: UserCategories :many
SELECT id, name, description, created_by, created_at FROM categories WHERE created_by = ?
`
func (q *Queries) UserCategories(ctx context.Context, createdBy string) ([]Category, error) {
rows, err := q.db.QueryContext(ctx, userCategories, createdBy)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Category
for rows.Next() {
var i Category
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedBy,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const userTasks = `-- name: UserTasks :many
SELECT id, name, description, completed, created_by, assigned_to, created_at, deadline FROM tasks WHERE created_by = ?
`
func (q *Queries) UserTasks(ctx context.Context, createdBy string) ([]Task, error) {
rows, err := q.db.QueryContext(ctx, userTasks, createdBy)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Completed,
&i.CreatedBy,
&i.AssignedTo,
&i.CreatedAt,
&i.Deadline,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

40
backend/dbutil/null.go Normal file
View file

@ -0,0 +1,40 @@
package dbutil
import (
"database/sql"
)
func SerializeMaybeString(str *string) sql.NullString {
if str == nil {
return sql.NullString{
String: "",
Valid: false,
}
}
return sql.NullString{
String: *str,
Valid: true,
}
}
func DeserializeMaybeString(str sql.NullString) *string {
if !str.Valid {
return nil
}
return &str.String
}
func SerializeMaybeInt(i *int64) sql.NullInt64 {
if i == nil {
return sql.NullInt64{
Int64: 0,
Valid: false,
}
}
return sql.NullInt64{
Int64: int64(*i),
Valid: true,
}
}

37
backend/log.go Normal file
View file

@ -0,0 +1,37 @@
package main
import (
"strings"
"git.red-panda.pet/pandaware/lipgloss-catppuccin"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
)
var (
palette = catppuccin.Mocha
styles *log.Styles
)
func levelStyle(level log.Level, color lipgloss.Color) lipgloss.Style {
return lipgloss.NewStyle().
Bold(true).
SetString(strings.TrimSpace(strings.ToUpper(level.String()))).
MaxWidth(6).
Align(lipgloss.Center).
Padding(0, 1).
Foreground(palette.Base).
Background(color)
}
func init() {
styles = log.DefaultStyles()
styles.Levels = map[log.Level]lipgloss.Style{
log.DebugLevel: levelStyle(log.DebugLevel, palette.Teal),
log.InfoLevel: levelStyle(log.InfoLevel, palette.Sapphire),
log.WarnLevel: levelStyle(log.WarnLevel, palette.Yellow),
log.ErrorLevel: levelStyle(log.ErrorLevel, palette.Red),
log.FatalLevel: levelStyle(log.FatalLevel, palette.Lavender),
}
}

56
backend/main.go Normal file
View file

@ -0,0 +1,56 @@
package main
import (
"context"
"database/sql"
_ "embed"
"flag"
"fmt"
"net/http"
"git.red-panda.pet/pandaware/house/backend/router"
"git.red-panda.pet/pandaware/house/backend/routes"
"github.com/charmbracelet/log"
_ "github.com/mattn/go-sqlite3"
)
var port uint
func init() {
flag.UintVar(&port, "port", 8088, "")
}
//go:embed schema.sql
var ddl string
func main() {
logger := log.Default()
logger.SetReportCaller(true)
logger.SetStyles(styles)
ctx := context.Background()
flag.Parse()
db, err := sql.Open("sqlite3", "file::memory:?cache=shared")
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
if err != nil {
logger.Fatal("unable to open db", "err", err)
}
if _, err := db.ExecContext(ctx, ddl); err != nil {
logger.Fatal("unable to migrate db", "err", err)
}
seedDevData(db)
r := router.NewRouter(logger, db)
r.SetPrefix("/api/v1")
routes.Register(r)
logger.Info("starting server", "port", port)
if err := http.ListenAndServe(fmt.Sprintf(":%d", port), r); err != nil {
logger.Fatal("unable start http server", "err", err)
}
}

View file

@ -0,0 +1,56 @@
package middleware
import (
"crypto/sha256"
"encoding/base64"
"errors"
"net/http"
"git.red-panda.pet/pandaware/house/backend/db"
"git.red-panda.pet/pandaware/house/backend/router"
)
func Authenticated(ctx *router.Context) error {
sessionCookie, err := ctx.Cookie("session")
if err != nil {
return ctx.Error(err, http.StatusUnauthorized, "login required")
}
sessionBs, err := base64.StdEncoding.DecodeString(sessionCookie.Value)
if err != nil {
return ctx.Error(err, http.StatusUnauthorized, "login required")
}
hashedKey := sha256.Sum256(sessionBs)
session, err := ctx.Query.GetSession(ctx, hashedKey[:])
if err != nil {
return ctx.Error(err, http.StatusUnauthorized, "login required")
}
user, err := ctx.Query.GetUser(ctx, session.UserID)
if err != nil {
return ctx.Error(err, http.StatusUnauthorized, "login required")
}
ctx.With(sessionKey, session)
ctx.With(userKey, user)
return nil
}
func Session(route router.AuthorizedRoute, ctx *router.Context) db.Session {
user, ok := ctx.Value(sessionKey).(db.Session)
if !ok {
panic(errors.New("middleware.Session cannot be used from an unauthenticated route"))
}
return user
}
func User(route router.AuthorizedRoute, ctx *router.Context) db.GetUserRow {
user, ok := ctx.Value(userKey).(db.GetUserRow)
if !ok {
panic(errors.New("middleware.User cannot be used from an unauthenticated route"))
}
return user
}

View file

@ -0,0 +1,10 @@
package middleware
type middlewareContextKey int
const (
sessionKey middlewareContextKey = iota
userKey
jsonBodyKey
paramsKey
)

View file

@ -0,0 +1,49 @@
package middleware
import (
"encoding/json"
"errors"
"net/http"
"git.red-panda.pet/pandaware/house/backend/router"
)
func ParseJSONBody[T any](ctx *router.Context) error {
contentType := ctx.Request.Header.Get("Content-Type")
if contentType != "application/json" {
return ctx.GenericError(nil, http.StatusBadRequest)
}
var body T
dec := json.NewDecoder(ctx.Request.Body)
err := dec.Decode(&body)
if err != nil {
return ctx.GenericError(err, http.StatusBadRequest)
}
ctx.With(jsonBodyKey, body)
return nil
}
func ParseJSONBodyWithValidator[T any](
ctx *router.Context,
r router.ValidatedBodyRoute,
validator func(value T) error,
) error {
err := ParseJSONBody[T](ctx)
if err != nil {
return err
}
body := JSONBody[T](r, ctx)
return validator(body)
}
func JSONBody[T any](r router.ValidatedBodyRoute, ctx *router.Context) T {
body, ok := ctx.Value(jsonBodyKey).(T)
if !ok {
panic(errors.New("middleware.JSONBody cannot be used with a route that doesn't implement router.ValidatedBodyRoute"))
}
return body
}

View file

@ -0,0 +1,33 @@
package middleware
import (
"strconv"
"git.red-panda.pet/pandaware/house/backend/router"
"github.com/google/uuid"
)
func ValidateUUIDParam(ctx *router.Context, key string) error {
param := ctx.Parameter(key)
if err := uuid.Validate(param); err != nil {
return ctx.Error(err, 400, "Bad path parameter "+key)
}
return nil
}
func ValidateIDParam(ctx *router.Context, key string) error {
param := ctx.Parameter(key)
v, err := strconv.ParseInt(param, 10, 64)
if err != nil {
return ctx.Error(err, 400, "Bad path parameter "+key)
}
ctx.With(paramsKey, v)
return nil
}
// TODO: expand this kinda api for more than just one param, better validation, etc.
func ParamID(r router.ValidatedParamRoute, ctx *router.Context) int64 {
return ctx.Value(paramsKey).(int64)
}

117
backend/query.sql Normal file
View file

@ -0,0 +1,117 @@
-- name: GetUser :one
SELECT id, username FROM users WHERE id = ? LIMIT 1;
-- name: GetUsers :many
SELECT * FROM users ORDER BY name;
-- name: GetUserPassword :one
SELECT id, password FROM users WHERE username = ? LIMIT 1;
-- name: CreateUser :one
INSERT INTO users (
id,
username
) VALUES (
?, ?
) RETURNING id, username;
-- name: UpdateUser :one
UPDATE users
set username = ?
WHERE id = ?
RETURNING id, username;
-- name: UpdateUserPassword :one
UPDATE users
set password = ?
WHERE id = ?
RETURNING id, username;
-- name: GetSession :one
SELECT * FROM sessions WHERE key = ? LIMIT 1;
-- name: CreateSession :one
INSERT INTO sessions (
key,
user_id,
created_at
) VALUES (?, ?, ?)
RETURNING *;
-- name: DeleteSession :exec
DELETE FROM sessions WHERE key = ? LIMIT 1 RETURNING *;
-- name: TaskByID :one
SELECT * FROM tasks WHERE id = ? LIMIT 1;
-- name: AllTasks :many
SELECT * FROM tasks;
-- name: TasksForUser :many
SELECT * FROM tasks WHERE assigned_to = ?;
-- name: UserTasks :many
SELECT * FROM tasks WHERE created_by = ?;
-- name: CategoriesOf :many
SELECT c.* FROM categories c
JOIN task_categories tc ON tc.category_id = c.id
WHERE tc.task_id IN (sqlc.slice('ids'));
-- name: TaskCategoriesOf :many
SELECT tc.* FROM task_categories tc
JOIN tasks t ON tc.task_id = t.id
WHERE tc.task_id IN (sqlc.slice('ids'));
-- name: AddTaskCategory :one
INSERT INTO task_categories (task_id, category_id)
VALUES (?, ?)
RETURNING *;
-- name: CreateTask :one
INSERT INTO tasks (
name,
description,
created_by,
assigned_to,
created_at,
deadline,
completed
) VALUES (?, ?, ?, ?, ?, ?, FALSE)
RETURNING *;
-- name: CompleteTask :one
UPDATE tasks SET completed = TRUE WHERE id = ? RETURNING *;
-- name: UpdateTask :one
UPDATE tasks SET
name = ?,
description = ?
WHERE id = ?
RETURNING *;
-- name: DeleteTask :exec
DELETE FROM tasks WHERE id = ? LIMIT 1 RETURNING *;
-- name: CreateCategory :one
INSERT INTO categories (
name, description, created_by, created_at
) VALUES (?, ?, ?, ?)
RETURNING *;
-- name: ListCategories :many
SELECT * FROM categories;
-- name: UpdateCategory :one
UPDATE categories SET
name = ?,
description = ?
WHERE id = ?
RETURNING *;
-- name: DeleteCategory :one
DELETE FROM categories WHERE id = ? LIMIT 1 RETURNING *;
-- name: UserCategories :many
SELECT * FROM categories WHERE created_by = ?;

204
backend/router/route.go Normal file
View file

@ -0,0 +1,204 @@
package router
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/url"
"time"
"git.red-panda.pet/pandaware/house/backend/db"
"github.com/charmbracelet/log"
)
type IRequestContext interface {
Search(key string) string
Parameter(key string) string
Cookie(name string) (*http.Cookie, error)
SetCookie(cookie *http.Cookie)
JSON(statusCode int, body any) error
Redirect(statusCode int, url string) error
Text(statusCode int, body string) error
Bytes(statusCode int, body []byte) error
With(key any, value any)
}
type RequestError struct {
Inner error `json:"-"`
RequestID string `json:"requestID"`
StatusCode int `json:"statusCode"`
Message string `json:"message"`
}
// Error implements error.
func (r *RequestError) Error() string {
return r.Inner.Error()
}
var _ error = new(RequestError)
type Context struct {
id string
statusCode int
start time.Time
log *log.Logger
context context.Context
search url.Values
resp http.ResponseWriter
Request *http.Request
DB *sql.Conn
Query *db.Queries
}
// Redirect implements IRequestContext.
func (r *Context) Redirect(statusCode int, url string) error {
http.Redirect(r, r.Request, url, statusCode)
return nil
}
// With implements IRequestContext.
func (r *Context) With(key, value any) {
r.context = context.WithValue(r.context, key, value)
}
// Bytes implements IRequestContext.
func (r *Context) Bytes(statusCode int, body []byte) error {
r.WriteHeader(statusCode)
_, err := r.Write(body)
return err
}
// Text implements IRequestContext.
func (r *Context) Text(statusCode int, body string) error {
r.Header().Set("Content-Type", "text/plain")
return r.Bytes(statusCode, []byte(body))
}
// JSON implements IRequestContext.
func (r *Context) JSON(statusCode int, body any) error {
enc := json.NewEncoder(r)
r.Header().Set("Content-Type", "application/json")
r.WriteHeader(statusCode)
return enc.Encode(body)
}
func (r *Context) Error(err error, statusCode int, message string) error {
return &RequestError{
Inner: err,
RequestID: r.id,
StatusCode: statusCode,
Message: message,
}
}
func (r *Context) GenericError(err error, statusCode int) error {
message := "Error"
switch statusCode {
case http.StatusBadRequest:
message = "Bad request"
case http.StatusUnauthorized:
message = "Unauthorized"
case http.StatusNotFound:
message = "Not found"
case http.StatusInternalServerError:
message = "Internal server error"
}
return r.Error(err, statusCode, message)
}
// Cookie implements IRequestContext.
func (r *Context) Cookie(name string) (*http.Cookie, error) {
return r.Request.Cookie(name)
}
// Parameter implements IRequestContext.
func (r *Context) Parameter(key string) string {
return r.Request.PathValue(key)
}
// Search implements IRequestContext.
func (r *Context) Search(key string) string {
if r.search == nil {
r.search = r.Request.URL.Query()
}
return r.search.Get(key)
}
// SetCookie implements IRequestContext.
func (r *Context) SetCookie(cookie *http.Cookie) {
http.SetCookie(r, cookie)
}
// Deadline implements context.Context.
func (r *Context) Deadline() (deadline time.Time, ok bool) {
return r.context.Deadline()
}
// Done implements context.Context.
func (r *Context) Done() <-chan struct{} {
return r.context.Done()
}
// Err implements context.Context.
func (r *Context) Err() error {
return r.context.Err()
}
// Value implements context.Context.
func (r *Context) Value(key any) any {
return r.context.Value(key)
}
// Header implements http.ResponseWriter.
func (r *Context) Header() http.Header {
return r.resp.Header()
}
// Write implements http.ResponseWriter.
func (r *Context) Write(bs []byte) (int, error) {
if r.statusCode == 0 {
r.statusCode = 200
}
r.log.Helper()
r.log.Info("",
"id", r.id,
"duration", time.Since(r.start),
"status", r.statusCode,
)
return r.resp.Write(bs)
}
// WriteHeader implements http.ResponseWriter.
func (r *Context) WriteHeader(statusCode int) {
r.statusCode = statusCode
r.resp.WriteHeader(statusCode)
}
var _ context.Context = new(Context)
var _ http.ResponseWriter = new(Context)
var _ IRequestContext = new(Context)
type Route interface {
Handle(ctx *Context) error
}
type AuthorizedRoute interface {
Authorize(ctx *Context) error
}
type ValidatedSearchRoute interface {
ValidateSearch(ctx *Context) error
}
type ValidatedBodyRoute interface {
ValidateBody(ctx *Context) error
}
type ValidatedParamRoute interface {
ValidateParams(ctx *Context) error
}

203
backend/router/router.go Normal file
View file

@ -0,0 +1,203 @@
package router
import (
"database/sql"
"fmt"
"net/http"
"time"
"git.red-panda.pet/pandaware/house/backend/db"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
type IRouter interface {
Register(method, pattern string, route Route)
GET(pattern string, route Route)
POST(pattern string, route Route)
HEAD(pattern string, route Route)
PUT(pattern string, route Route)
PATCH(pattern string, route Route)
DELETE(pattern string, route Route)
OPTIONS(pattern string, route Route)
}
type Router struct {
log *log.Logger
mux *http.ServeMux
db *sql.DB
prefix string
}
func NewRouter(logger *log.Logger, db *sql.DB) *Router {
r := new(Router)
r.mux = http.NewServeMux()
r.log = logger
r.db = db
return r
}
func (router *Router) SetPrefix(prefix string) {
router.prefix = prefix
}
func (router *Router) NewContext(w http.ResponseWriter, r *http.Request) (*Context, error) {
router.log.Helper()
ctx := new(Context)
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
conn, err := router.db.Conn(r.Context())
if err != nil {
return nil, err
}
method := r.Method
if method == "" {
method = "GET"
}
path := r.URL.EscapedPath()
ctx.id = id.String()
ctx.start = time.Now()
ctx.resp = w
ctx.Request = r
ctx.log = router.log.WithPrefix(method + " " + path)
ctx.DB = conn
ctx.Query = db.New(ctx.DB)
ctx.context = r.Context()
return ctx, nil
}
func (router *Router) handleError(ctx *Context, err error) {
if err == nil {
return
}
if reqErr, ok := err.(*RequestError); ok {
err = ctx.JSON(reqErr.StatusCode, reqErr)
ctx.log.Warn("error during request", "err", reqErr.Inner)
if err != nil {
panic(err)
}
return
}
panic(err)
}
// All implements IRouter.
func (router *Router) Register(method, path string, route Route) {
router.log.Helper()
routeImpl := any(route)
authorize, implAuthorized := routeImpl.(AuthorizedRoute)
validateBody, implBodyValidation := routeImpl.(ValidatedBodyRoute)
validateParams, implParamValidation := routeImpl.(ValidatedParamRoute)
validateSearch, implSearchValidation := routeImpl.(ValidatedSearchRoute)
pattern := fmt.Sprintf("%s %s%s", method, router.prefix, path)
router.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
ctx, err := router.NewContext(w, r)
//TODO: don't panic :3
if err != nil {
panic(err)
}
if implAuthorized {
err = authorize.Authorize(ctx)
}
if err != nil {
router.handleError(ctx, err)
return
}
if implSearchValidation {
err = validateSearch.ValidateSearch(ctx)
}
if err != nil {
router.handleError(ctx, err)
return
}
if implParamValidation {
err = validateParams.ValidateParams(ctx)
}
if err != nil {
router.handleError(ctx, err)
return
}
if implBodyValidation {
err = validateBody.ValidateBody(ctx)
}
if err != nil {
router.handleError(ctx, err)
return
}
err = route.Handle(ctx)
router.handleError(ctx, err)
})
}
// GET implements IRouter.
func (router *Router) GET(pattern string, route Route) {
router.Register("GET ", pattern, route)
}
// DELETE implements IRouter.
func (router *Router) DELETE(pattern string, route Route) {
router.Register("DELETE", pattern, route)
}
// HEAD implements IRouter.
func (router *Router) HEAD(pattern string, route Route) {
router.Register("HEAD", pattern, route)
}
// OPTIONS implements IRouter.
func (router *Router) OPTIONS(pattern string, route Route) {
router.Register("OPTIONS", pattern, route)
}
// POST implements IRouter.
func (router *Router) POST(pattern string, route Route) {
router.Register("POST", pattern, route)
}
// PUT implements IRouter.
func (router *Router) PUT(pattern string, route Route) {
router.Register("PUT", pattern, route)
}
// PATCH implements IRouter.
func (router *Router) PATCH(pattern string, route Route) {
router.Register("PATCH", pattern, route)
}
// ServeHTTP implements http.Handler.
func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
router.log.Helper()
router.log.Warn("recovered from panic", "err", rec)
}
}()
router.mux.ServeHTTP(w, r)
}
var _ http.Handler = new(Router)
var _ IRouter = new(Router)

View file

@ -0,0 +1,37 @@
package routes
import (
"net/http"
"git.red-panda.pet/pandaware/house/backend/db"
"git.red-panda.pet/pandaware/house/backend/middleware"
"git.red-panda.pet/pandaware/house/backend/router"
)
func init() {
routes["GET"]["/category"] = new(CategoriesGET)
}
type categoriesResponse struct {
Categories []db.Category `json:"categories"`
}
type CategoriesGET struct{}
// Authorize implements router.AuthorizedRoute.
func (c *CategoriesGET) Authorize(ctx *router.Context) error {
return middleware.Authenticated(ctx)
}
// Handle implements router.Route.
func (c *CategoriesGET) Handle(ctx *router.Context) error {
categories, err := ctx.Query.ListCategories(ctx)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
return ctx.JSON(200, categoriesResponse{
Categories: categories,
})
}
var _ router.AuthorizedRoute = new(CategoriesGET)

View file

@ -0,0 +1,76 @@
package routes
import (
"net/http"
"time"
"git.red-panda.pet/pandaware/house/backend/db"
"git.red-panda.pet/pandaware/house/backend/dbutil"
"git.red-panda.pet/pandaware/house/backend/middleware"
"git.red-panda.pet/pandaware/house/backend/router"
)
func init() {
routes["POST"]["/category"] = new(CategoryCreate)
}
type categoryCreateBody struct {
Name string `json:"name"`
Description *string `json:"description"`
}
type categoryCreateResponse struct {
Category db.Category `json:"category"`
}
type CategoryCreate struct{}
// ValidateBody implements router.ValidatedBodyRoute.
func (c *CategoryCreate) ValidateBody(ctx *router.Context) error {
return middleware.ParseJSONBodyWithValidator(
ctx, c, func(value categoryCreateBody) error {
if len(value.Name) < 5 {
return ctx.Error(nil, 400, "Name too short")
}
if value.Description != nil {
description := *value.Description
if len(description) > 100 {
return ctx.Error(nil, 400, "Description too long")
}
}
return nil
},
)
}
// Authorize implements router.AuthorizedRoute.
func (c *CategoryCreate) Authorize(ctx *router.Context) error {
return middleware.Authenticated(ctx)
}
// Handle implements router.Route.
func (c *CategoryCreate) Handle(ctx *router.Context) error {
user := middleware.User(c, ctx)
body := middleware.JSONBody[categoryCreateBody](c, ctx)
category, err := ctx.Query.CreateCategory(ctx, db.CreateCategoryParams{
Name: body.Name,
Description: dbutil.SerializeMaybeString(body.Description),
CreatedBy: user.ID,
CreatedAt: time.Now().Unix(),
})
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
return ctx.JSON(200, categoryCreateResponse{
Category: category,
})
}
var _ router.AuthorizedRoute = new(CategoryCreate)
var _ router.ValidatedBodyRoute = new(CategoryCreate)

86
backend/routes/login.go Normal file
View file

@ -0,0 +1,86 @@
package routes
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"net/http"
"time"
"git.red-panda.pet/pandaware/house/backend/db"
"git.red-panda.pet/pandaware/house/backend/middleware"
"git.red-panda.pet/pandaware/house/backend/router"
"golang.org/x/crypto/bcrypt"
)
func init() {
routes["POST"]["/login"] = new(Login)
}
type loginBody struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponse struct {
User db.GetUserRow `json:"user"`
}
type Login struct{}
// ValidateBody implements router.ValidatedBodyRoute.
func (l *Login) ValidateBody(ctx *router.Context) error {
return middleware.ParseJSONBody[loginBody](ctx)
}
// Handle implements router.Route.
func (l *Login) Handle(ctx *router.Context) error {
body := middleware.JSONBody[loginBody](l, ctx)
user, err := ctx.Query.GetUserPassword(ctx, body.Username)
if err != nil {
return ctx.Error(err, http.StatusBadRequest, "Invalid credentials")
}
err = bcrypt.CompareHashAndPassword(user.Password, []byte(body.Password))
if err != nil {
return ctx.Error(err, http.StatusBadRequest, "Invalid credentials")
}
tokenBs := make([]byte, 32)
_, err = rand.Read(tokenBs)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
encodedToken := base64.StdEncoding.EncodeToString(tokenBs)
hashedToken := sha256.Sum256(tokenBs)
_, err = ctx.Query.CreateSession(ctx, db.CreateSessionParams{
Key: hashedToken[:],
UserID: user.ID,
CreatedAt: time.Now().Unix(),
})
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
loggedInUser, err := ctx.Query.GetUser(ctx, user.ID)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
ctx.SetCookie(&http.Cookie{
Name: "session",
Value: encodedToken,
Path: "/",
HttpOnly: true,
})
return ctx.JSON(200, loginResponse{
User: loggedInUser,
})
}
var _ router.ValidatedBodyRoute = new(Login)

22
backend/routes/routes.go Normal file
View file

@ -0,0 +1,22 @@
package routes
import (
"git.red-panda.pet/pandaware/house/backend/router"
)
var routes = map[string]map[string]router.Route{
"GET": map[string]router.Route{},
"POST": map[string]router.Route{},
"PATCH": map[string]router.Route{},
"DELETE": map[string]router.Route{},
"PUT": map[string]router.Route{},
"OPTIONS": map[string]router.Route{},
}
func Register(r router.IRouter) {
for method, patterns := range routes {
for pattern, route := range patterns {
r.Register(method, pattern, route)
}
}
}

View file

@ -0,0 +1,89 @@
package routes
import (
// "net/http"
"fmt"
"net/http"
"git.red-panda.pet/pandaware/house/backend/db"
"git.red-panda.pet/pandaware/house/backend/middleware"
"git.red-panda.pet/pandaware/house/backend/router"
)
func init() {
routes["POST"]["/task/{id}/categories"] = new(TaskAddCategories)
}
type taskAddCategoriesBody struct {
TaskID int64 `json:"task_id"`
CategoryIDs []int64 `json:"category_ids"`
}
type taskAddCategoriesResponse struct {
TaskCategories []db.TaskCategory `json:"task_categories"`
}
type TaskAddCategories struct{}
// ValidateBody implements router.ValidatedBodyRoute.
func (t *TaskAddCategories) ValidateBody(ctx *router.Context) error {
return middleware.ParseJSONBody[taskAddCategoriesBody](ctx)
}
// ValidateParams implements router.ValidatedParamRoute.
func (t *TaskAddCategories) ValidateParams(ctx *router.Context) error {
return middleware.ValidateUUIDParam(ctx, "id")
}
// Authorize implements router.AuthorizedRoute.
func (t *TaskAddCategories) Authorize(ctx *router.Context) error {
return middleware.Authenticated(ctx)
}
// Handle implements router.Route.
func (t *TaskAddCategories) Handle(ctx *router.Context) error {
user := middleware.User(t, ctx)
body := middleware.JSONBody[taskAddCategoriesBody](t, ctx)
tx, err := ctx.DB.BeginTx(ctx, nil)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
query := ctx.Query.WithTx(tx)
task, err := query.TaskByID(ctx, body.TaskID)
if err != nil {
tx.Rollback()
return ctx.GenericError(err, http.StatusNotFound)
}
if user.ID != task.CreatedBy && user.ID != task.AssignedTo {
return ctx.GenericError(err, http.StatusForbidden)
}
taskCategories := make([]db.TaskCategory, len(body.CategoryIDs))
for _, categoryID := range body.CategoryIDs {
tc, err := query.AddTaskCategory(ctx, db.AddTaskCategoryParams{
TaskID: task.ID,
CategoryID: categoryID,
})
if err != nil {
tx.Rollback()
return ctx.Error(err, http.StatusNotFound, fmt.Sprintf("Category ID %d not found", categoryID))
}
taskCategories = append(taskCategories, tc)
}
return ctx.JSON(200, taskAddCategoriesResponse{
TaskCategories: taskCategories,
})
}
var _ router.AuthorizedRoute = new(TaskAddCategories)
var _ router.ValidatedBodyRoute = new(TaskAddCategories)
var _ router.ValidatedParamRoute = new(TaskAddCategories)

View file

@ -0,0 +1,57 @@
package routes
import (
"net/http"
"git.red-panda.pet/pandaware/house/backend/db"
"git.red-panda.pet/pandaware/house/backend/middleware"
"git.red-panda.pet/pandaware/house/backend/router"
)
func init() {
routes["POST"]["/task/{id}/complete"] = new(TaskComplete)
}
type taskCompleteResponse struct {
Task db.Task `json:"task"`
}
type TaskComplete struct{}
// ValidateParams implements router.ValidatedParamRoute.
func (t *TaskComplete) ValidateParams(ctx *router.Context) error {
return middleware.ValidateIDParam(ctx, "id")
}
// Authorize implements router.AuthorizedRoute.
func (t *TaskComplete) Authorize(ctx *router.Context) error {
return middleware.Authenticated(ctx)
}
// Handle implements router.Route.
func (t *TaskComplete) Handle(ctx *router.Context) error {
id := middleware.ParamID(t, ctx)
tx, err := ctx.DB.BeginTx(ctx, nil)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
query := ctx.Query.WithTx(tx)
task, err := query.CompleteTask(ctx, id)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
err = tx.Commit()
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
return ctx.JSON(200, taskCompleteResponse{
Task: task,
})
}
var _ router.AuthorizedRoute = new(TaskComplete)
var _ router.ValidatedParamRoute = new(TaskComplete)

View file

@ -0,0 +1,93 @@
package routes
import (
"net/http"
"time"
"git.red-panda.pet/pandaware/house/backend/db"
"git.red-panda.pet/pandaware/house/backend/dbutil"
"git.red-panda.pet/pandaware/house/backend/middleware"
"git.red-panda.pet/pandaware/house/backend/router"
)
func init() {
routes["POST"]["/task"] = new(TaskCreate)
}
type taskCreateBody struct {
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Deadline *int64 `json:"deadline,omitempty"`
}
type taskCreateResponse struct {
Task db.Task `json:"task"`
}
type TaskCreate struct{}
// Authorize implements router.AuthorizedRoute.
func (t *TaskCreate) Authorize(ctx *router.Context) error {
return middleware.Authenticated(ctx)
}
// ValidateBody implements router.ValidatedBodyRoute.
func (t *TaskCreate) ValidateBody(ctx *router.Context) error {
return middleware.ParseJSONBodyWithValidator[taskCreateBody](
ctx, t, func(value taskCreateBody) error {
if len(value.Name) > 64 {
return ctx.Error(nil, 400, "name too long")
}
if len(value.Name) < 3 {
return ctx.Error(nil, 400, "name too short")
}
if value.Description != nil {
description := *value.Description
if len(description) > 200 {
return ctx.Error(nil, 400, "description too long")
}
}
if value.Deadline != nil {
deadline := *value.Deadline
if deadline < 0 {
return ctx.Error(nil, 400, "deadline cannot be below 0")
}
if deadline < time.Now().Unix() {
return ctx.Error(nil, 400, "deadline cannot be in the past")
}
}
return nil
},
)
}
// Handle implements router.Route.
func (t *TaskCreate) Handle(ctx *router.Context) error {
user := middleware.User(t, ctx)
body := middleware.JSONBody[taskCreateBody](t, ctx)
task, err := ctx.Query.CreateTask(ctx, db.CreateTaskParams{
Name: body.Name,
Description: dbutil.SerializeMaybeString(body.Description),
CreatedBy: user.ID,
AssignedTo: user.ID,
CreatedAt: time.Now().Unix(),
Deadline: dbutil.SerializeMaybeInt(body.Deadline),
})
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
return ctx.JSON(200, task)
}
var _ router.AuthorizedRoute = new(TaskCreate)
var _ router.ValidatedBodyRoute = new(TaskCreate)

View file

@ -0,0 +1,57 @@
package routes
import (
"net/http"
"git.red-panda.pet/pandaware/house/backend/db"
"git.red-panda.pet/pandaware/house/backend/middleware"
"git.red-panda.pet/pandaware/house/backend/router"
)
func init() {
routes["GET"]["/task"] = new(TasksGET)
}
type tasksResponse struct {
Tasks []db.Task `json:"tasks"`
Categories []db.Category `json:"categories"`
}
type TasksGET struct{}
// Authorize implements router.AuthorizedRoute.
func (t *TasksGET) Authorize(ctx *router.Context) error {
return middleware.Authenticated(ctx)
}
// Handle implements router.Route.
func (t *TasksGET) Handle(ctx *router.Context) error {
tx, err := ctx.DB.BeginTx(ctx, nil)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
query := ctx.Query.WithTx(tx)
tasks, err := query.AllTasks(ctx)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
taskIDs := make([]int64, len(tasks))
for i, t := range tasks {
taskIDs[i] = t.ID
}
categories, err := query.CategoriesOf(ctx, taskIDs)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
return ctx.JSON(200, tasksResponse{
Tasks: tasks,
Categories: categories,
})
}
var _ router.AuthorizedRoute = new(TasksGET)

View file

@ -0,0 +1,47 @@
package routes
import (
"net/http"
"git.red-panda.pet/pandaware/house/backend/middleware"
"git.red-panda.pet/pandaware/house/backend/router"
)
func init() {
routes["GET"]["/user/{id}"] = new(UserGET)
}
type UserGET struct{}
// ValidateParams implements router.ValidatedParamRoute.
func (u *UserGET) ValidateParams(ctx *router.Context) error {
if ctx.Parameter("id") == "me" {
return nil
}
return middleware.ValidateUUIDParam(ctx, "id")
}
// Authorize implements router.AuthorizedRoute.
func (u *UserGET) Authorize(ctx *router.Context) error {
return middleware.Authenticated(ctx)
}
// Handle implements router.Route.
func (u *UserGET) Handle(ctx *router.Context) error {
currentUser := middleware.User(u, ctx)
id := ctx.Parameter("id")
if id == "me" {
id = currentUser.ID
}
user, err := ctx.Query.GetUser(ctx, id)
if err != nil {
return ctx.Error(err, http.StatusNotFound, "user not found")
}
return ctx.JSON(200, user)
}
var _ router.AuthorizedRoute = new(UserGET)
var _ router.ValidatedParamRoute = new(UserGET)

View file

@ -0,0 +1,91 @@
package routes
import (
"database/sql"
"errors"
"net/http"
"git.red-panda.pet/pandaware/house/backend/db"
"git.red-panda.pet/pandaware/house/backend/middleware"
"git.red-panda.pet/pandaware/house/backend/router"
)
func init() {
routes["GET"]["/user/{id}/tasks"] = new(UserTasks)
}
type userTasksResponse struct {
Tasks []db.Task `json:"tasks"`
TaskCategories []db.TaskCategory `json:"task_categories"`
Categories []db.Category `json:"categories"`
}
type UserTasks struct{}
// Authorize implements router.AuthorizedRoute.
func (u *UserTasks) Authorize(ctx *router.Context) error {
return middleware.Authenticated(ctx)
}
// ValidateParams implements router.ValidatedParamRoute.
func (u *UserTasks) ValidateParams(ctx *router.Context) error {
if ctx.Parameter("id") == "me" {
return nil
}
return middleware.ValidateUUIDParam(ctx, "id")
}
// Handle implements router.Route.
func (u *UserTasks) Handle(ctx *router.Context) error {
user := middleware.User(u, ctx)
id := ctx.Parameter("id")
if id == "me" {
id = user.ID
}
tx, err := ctx.DB.BeginTx(ctx, &sql.TxOptions{
ReadOnly: true,
})
query := ctx.Query.WithTx(tx)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
tasks, err := query.TasksForUser(ctx, id)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return ctx.GenericError(err, http.StatusInternalServerError)
}
taskIDs := make([]int64, len(tasks))
for i, task := range tasks {
taskIDs[i] = task.ID
}
taskCategories, err := query.TaskCategoriesOf(ctx, taskIDs)
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
categories, err := query.CategoriesOf(ctx, taskIDs)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return ctx.GenericError(err, http.StatusInternalServerError)
}
err = tx.Commit()
if err != nil {
return ctx.GenericError(err, http.StatusInternalServerError)
}
return ctx.JSON(200, userTasksResponse{
Tasks: append([]db.Task{}, tasks...),
TaskCategories: append([]db.TaskCategory{}, taskCategories...),
Categories: append([]db.Category{}, categories...),
})
}
var _ router.AuthorizedRoute = new(UserTasks)
var _ router.ValidatedParamRoute = new(UserTasks)

39
backend/schema.sql Normal file
View file

@ -0,0 +1,39 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password BLOB
);
CREATE TABLE IF NOT EXISTS sessions (
key BLOB PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE,
created_at INT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
completed INT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE,
assigned_to TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE,
created_at INT NOT NULL,
deadline INT
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
created_by TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE,
created_at INT NOT NULL
);
CREATE TABLE IF NOT EXISTS task_categories (
task_id INT NOT NULL REFERENCES tasks(id) ON UPDATE CASCADE,
category_id INT NOT NULL REFERENCES category(id) ON UPDATE CASCADE,
PRIMARY KEY (task_id, category_id)
);

36
backend/seed.go Normal file
View file

@ -0,0 +1,36 @@
package main
import (
"context"
"database/sql"
"git.red-panda.pet/pandaware/house/backend/db"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
func seedDevData(sqlite *sql.DB) {
ctx := context.Background()
tx := db.New(sqlite)
id := uuid.Must(uuid.NewV7())
user, err := tx.CreateUser(ctx, db.CreateUserParams{
ID: id.String(),
Username: "red",
})
if err != nil {
panic(err)
}
password, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
_, err = tx.UpdateUserPassword(ctx, db.UpdateUserPasswordParams{
ID: user.ID,
Password: password,
})
if err != nil {
panic(err)
}
}

10
backend/sqlc.yaml Normal file
View file

@ -0,0 +1,10 @@
version: "2"
sql:
- engine: "sqlite"
queries: "query.sql"
schema: "schema.sql"
gen:
go:
package: "db"
out: "db"
emit_json_tags: true

26
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.tanstack

54
frontend/README.md Normal file
View file

@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
});
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
});
```

28
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
);

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

46
frontend/package.json Normal file
View file

@ -0,0 +1,46 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write ."
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/themes": "^3.2.1",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-query-devtools": "^5.80.7",
"@tanstack/react-router": "^1.121.12",
"@tanstack/react-router-devtools": "^1.121.12",
"radix-ui": "^1.4.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tanstack/router-plugin": "^1.121.12",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react-swc": "^3.9.0",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"@swc/core",
"esbuild"
]
}
}

4475
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

201
frontend/src/api.ts Normal file
View file

@ -0,0 +1,201 @@
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
export interface User {
id: string;
username: string;
}
export interface Category {
id: number;
name: string;
description?: string;
created_by: string;
created_at: number;
}
export interface Task {
id: number;
name: string;
description?: string;
completed: number;
created_by: string;
assigned_to: string;
created_at: number;
deadline?: number;
}
export interface TaskCategory {
task_id: number;
category_id: number;
}
export interface ErrorResponse {
request_id: string;
code: number;
message: string;
}
declare module '@tanstack/react-query' {
interface Register {
defaultError: ErrorResponse;
}
}
type HTTPMethod = 'GET' | 'POST' | 'DELETE';
type CanHaveBody<M extends HTTPMethod> = M extends 'GET' ? false : true;
export async function request<M extends HTTPMethod, R = unknown, T = unknown>(
method: M,
endpoint: string,
body?: CanHaveBody<M> extends true ? T : undefined,
): Promise<R> {
const resp = await fetch(endpoint, {
method,
body: body ? JSON.stringify(body) : undefined,
headers: {
'Content-Type': 'application/json',
},
});
const responseBody = await resp.json();
if (resp.status != 200) {
throw responseBody;
}
return responseBody as R;
}
export function whoamiOptions() {
return queryOptions({
queryKey: ['user', 'me'],
async queryFn() {
return request<'GET', User>('GET', '/api/v1/user/me');
},
});
}
export function useCurrentUser() {
return useQuery(whoamiOptions());
}
export function userOptions(userID: string) {
return queryOptions({
queryKey: ['user', userID],
async queryFn() {
return request<'GET', User>('GET', '/api/v1/user/' + userID);
},
});
}
export function useUser(userID: string) {
return useQuery(userOptions(userID));
}
export function userTasksOptions(userID: string) {
return queryOptions({
queryKey: ['user', userID, 'tasks'],
async queryFn() {
return request<'GET', { tasks: Task[] }>(
'GET',
'/api/v1/user/' + userID + '/tasks',
);
},
});
}
export function useUserTasks(userID: string) {
return useQuery(userTasksOptions(userID));
}
export interface CreateTaskBody {
name: string;
description?: string;
deadline?: number;
}
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
async mutationFn(body: CreateTaskBody): Promise<{
task: Task;
}> {
return request('POST', '/api/v1/task', body);
},
onSettled() {
queryClient.invalidateQueries(userTasksOptions('me'));
},
});
}
export interface CreateCategoryBody {
name: string;
description?: string;
color?: number;
}
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
async mutationFn(body: CreateCategoryBody): Promise<{
category: Category;
}> {
return request('POST', '/api/v1/category', body);
},
onSettled() {
queryClient.invalidateQueries(categoriesOptions());
},
});
}
export function useAddTaskCategories(taskID: number) {
return useMutation({
async mutationFn(categoryIDs: number[]) {
return request<'POST', { taskCategories: TaskCategory[] }>(
'POST',
'/api/v1/task/' + taskID + '/categories',
{
categoryIDs,
},
);
},
});
}
export function categoriesOptions(enabled?: boolean) {
return queryOptions({
queryKey: ['categories'],
async queryFn() {
return await request<'GET', { categories: Category[] }>(
'GET',
'/api/v1/category',
);
},
enabled,
});
}
export function useCategories(enabled?: boolean) {
return useQuery(categoriesOptions(enabled));
}
export function useCompleteTask(taskID: number) {
const queryClient = useQueryClient();
return useMutation({
async mutationFn() {
return request<'POST', { task: Task }>(
'POST',
'/api/v1/task' + taskID + '/complete',
);
},
onSettled() {
queryClient.invalidateQueries(userTasksOptions('me'));
},
});
}

17
frontend/src/app.css Normal file
View file

@ -0,0 +1,17 @@
*,
::before,
::after {
box-sizing: border-box;
padding: 0;
margin: 0;
}
body {
padding: 0;
margin: 0;
overflow: hidden;
}
.accordion-toggle[data-state='open'] {
rotate: 180deg;
}

View file

@ -0,0 +1,51 @@
import { PlusIcon } from '@radix-ui/react-icons';
import { Button, CheckboxCards, Flex, Popover } from '@radix-ui/themes';
import { useState } from 'react';
import { useCategories } from '../api';
interface CategorySelectProps {
categoryIDs: Set<number>;
setCategoryIDs(ids: Set<number>): void;
}
export default function CategorySelect({
categoryIDs,
setCategoryIDs,
}: CategorySelectProps) {
const [open, setOpen] = useState(false);
const categories = useCategories(open);
const checked = [...categoryIDs].map((v) => v.toString());
function setChecked(ids: string[]) {
const parsed = ids.map((v) => parseInt(v)).filter(Number.isSafeInteger);
setCategoryIDs(new Set(parsed));
}
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>
<Button>
<PlusIcon /> Add categories
</Button>
</Popover.Trigger>
<Popover.Content>
<Flex asChild direction="column" gap="2">
<CheckboxCards.Root
value={checked}
onValueChange={setChecked}
>
{categories.data?.categories.map((c) => (
<CheckboxCards.Item value={c.id.toString()}>
{c.name}
</CheckboxCards.Item>
))}
</CheckboxCards.Root>
</Flex>
<Popover.Close>
<Button>Done</Button>
</Popover.Close>
</Popover.Content>
</Popover.Root>
);
}

View file

@ -0,0 +1,90 @@
import {
CardStackPlusIcon,
CrossCircledIcon,
Pencil1Icon,
PlusIcon,
} from '@radix-ui/react-icons';
import {
Button,
Callout,
Dialog,
Flex,
Grid,
Spinner,
TextField,
} from '@radix-ui/themes';
import { useState, type FormEvent } from 'react';
import { useCreateCategory } from '../api';
export default function CreateCategory() {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const createCategory = useCreateCategory();
async function submit(ev: FormEvent<HTMLFormElement>) {
ev.preventDefault();
await createCategory.mutateAsync({
name,
});
setOpen(false);
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>
<Button variant="soft">
<CardStackPlusIcon />
Create category
</Button>
</Dialog.Trigger>
<Dialog.Content maxWidth="400px">
<Dialog.Title>Create a new category</Dialog.Title>
<Dialog.Description></Dialog.Description>
{createCategory.isError && (
<Callout.Root>
<Callout.Icon>
<CrossCircledIcon />
</Callout.Icon>
<Callout.Text>
{createCategory.error.message}
</Callout.Text>
</Callout.Root>
)}
<Flex asChild direction="column" gap="4">
<form onSubmit={submit}>
<TextField.Root
placeholder="Category name..."
value={name}
onChange={(ev) => setName(ev.currentTarget.value)}
>
<TextField.Slot>
<Pencil1Icon />
</TextField.Slot>
</TextField.Root>
<Grid columns="2" gap="2">
<Button
type="submit"
disabled={createCategory.isPending}
>
<Spinner loading={createCategory.isPending}>
<PlusIcon />
</Spinner>
Create
</Button>
<Dialog.Close>
<Button
type="button"
variant="soft"
color="gray"
>
Cancel
</Button>
</Dialog.Close>
</Grid>
</form>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}

View file

@ -0,0 +1,85 @@
import { CalendarIcon, PlusIcon } from '@radix-ui/react-icons';
import {
Button,
Dialog,
Flex,
Grid,
TextArea,
TextField,
} from '@radix-ui/themes';
import { useState, type FormEvent } from 'react';
import { useCreateTask } from '../api';
export default function CreateTask() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [deadline, _setDeadline] = useState(0);
const createMutation = useCreateTask();
async function submit(ev: FormEvent<HTMLFormElement>) {
ev.preventDefault();
await createMutation.mutateAsync({
name,
description,
deadline: deadline > 0 ? deadline : undefined,
});
}
return (
<Dialog.Root>
<Dialog.Trigger>
<Button>
<PlusIcon />
Create task
</Button>
</Dialog.Trigger>
<Dialog.Content size="3" maxWidth="400px">
<Flex direction="column" gap="2">
<Dialog.Title>Create a new task</Dialog.Title>
<Dialog.Description></Dialog.Description>
<Flex direction="column" gap="4" asChild>
<form onSubmit={submit}>
<TextField.Root
placeholder="Task name"
value={name}
onChange={(ev) =>
setName(ev.currentTarget.value)
}
></TextField.Root>
<TextArea
placeholder="Description... (optional)"
value={description}
onChange={(ev) =>
setDescription(ev.currentTarget.value)
}
/>
<TextField.Root type="datetime-local">
<TextField.Slot>
<CalendarIcon />
</TextField.Slot>
</TextField.Root>
<Grid gap="2" columns="2">
<Button type="submit">
<PlusIcon />
Create
</Button>
<Dialog.Close>
<Button
type="button"
variant="soft"
color="gray"
>
Cancel
</Button>
</Dialog.Close>
</Grid>
</form>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}

View file

@ -0,0 +1,54 @@
import {
Card,
Checkbox,
ChevronDownIcon,
Flex,
IconButton,
Text,
} from '@radix-ui/themes';
import { Accordion } from 'radix-ui';
import { type Task } from '../api';
import UserAvatar from './user-avatar';
interface TaskCardProps {
task: Task;
}
export default function TaskCard({ task }: TaskCardProps) {
// const completeTask = useCompleteTask(task.id);
return (
<Card>
<Accordion.Item value={task.id.toString()}>
<Accordion.Header>
<Flex direction="row" align="center" gap="4">
<UserAvatar userID={task.assigned_to} />
<Flex flexGrow="1" direction="column">
<Text>{task.name}</Text>
</Flex>
<Checkbox checked={task.completed === 1} size="3" />
<Flex
align="center"
justify="center"
px="3"
py="2"
my="auto"
>
<Accordion.Trigger asChild>
<IconButton
className="accordion-toggle"
size="4"
color="gray"
variant="ghost"
>
<ChevronDownIcon />
</IconButton>
</Accordion.Trigger>
</Flex>
</Flex>
</Accordion.Header>
<Accordion.Content>hi</Accordion.Content>
</Accordion.Item>
</Card>
);
}

View file

@ -0,0 +1,16 @@
import { Avatar, Skeleton } from '@radix-ui/themes';
import { useUser } from '../api';
interface UserAvatarProps {
userID: string;
}
export default function UserAvatar({ userID }: UserAvatarProps) {
const user = useUser(userID);
return (
<Skeleton loading={user.isPending}>
<Avatar fallback={user.data?.username.charAt(0) ?? '?'} />
</Skeleton>
);
}

View file

@ -0,0 +1,30 @@
import { ExitIcon } from '@radix-ui/react-icons';
import { Button, Card, Flex, Skeleton, Spinner, Text } from '@radix-ui/themes';
import { useUser } from '../api';
import UserAvatar from './user-avatar';
interface UserCardProps {
userID: string;
}
export default function UserCard({ userID }: UserCardProps) {
const { data: user } = useUser(userID);
return (
<Card>
<Flex pr="1" direction="row" gap="2" align="center">
<Skeleton loading={!user}>
<UserAvatar userID={userID} />
</Skeleton>
<Skeleton loading={!user}>
<Text>{user?.username}</Text>
</Skeleton>
<Button variant="soft" ml="auto" disabled={!user}>
<Spinner loading={!user}>
<ExitIcon />
</Spinner>
Logout
</Button>
</Flex>
</Card>
);
}

41
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,41 @@
import { Theme } from '@radix-ui/themes';
import './app.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { routeTree } from './routeTree.gen';
import '@radix-ui/themes/styles.css';
const queryClient = new QueryClient({});
const router = createRouter({
routeTree,
context: {
queryClient,
},
});
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Theme
appearance="dark"
panelBackground="solid"
accentColor="ruby"
grayColor="auto"
// radius="full"
radius="large"
>
<RouterProvider router={router} />
</Theme>
</QueryClientProvider>
</StrictMode>,
);

View file

@ -0,0 +1,77 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/login': typeof LoginRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/login'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/login'
id: '__root__' | '/' | '/login'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View file

@ -0,0 +1,25 @@
import { ThemePanel } from '@radix-ui/themes';
import type { QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import * as React from 'react';
export interface RootContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RootContext>()({
component: RootComponent,
});
function RootComponent() {
return (
<React.Fragment>
<Outlet />
<TanStackRouterDevtools />
<ThemePanel defaultOpen={false} />
<ReactQueryDevtools />
</React.Fragment>
);
}

View file

@ -0,0 +1,38 @@
import { Container, Flex, Grid } from '@radix-ui/themes';
import { createFileRoute } from '@tanstack/react-router';
import { Accordion } from 'radix-ui';
import { useUserTasks } from '../api';
import CreateCategory from '../components/create-category';
import CreateTask from '../components/create-task';
import TaskCard from '../components/task-card';
import UserCard from '../components/user-card';
export const Route = createFileRoute('/')({
component: RouteComponent,
});
function RouteComponent() {
const tasks = useUserTasks('me');
return (
<Flex direction="column" width="100vw" height="100vh">
<Container size="1" p="8">
<Flex direction="column" gap="4">
<UserCard userID="me" />
<Grid gap="2" columns="2">
<CreateTask />
<CreateCategory />
</Grid>
<Flex direction="column" gap="2" asChild>
<Accordion.Root type="single" collapsible>
{tasks.data?.tasks.map((t) => (
<TaskCard key={t.id} task={t} />
))}
</Accordion.Root>
</Flex>
</Flex>
</Container>
</Flex>
);
}

View file

@ -0,0 +1,81 @@
import { LockClosedIcon, PersonIcon } from '@radix-ui/react-icons';
import { Button, Card, Flex, TextField } from '@radix-ui/themes';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react';
import { request, type User } from '../api';
export const Route = createFileRoute('/login')({
component: RouteComponent,
});
function RouteComponent() {
const navigate = Route.useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const loginMutation = useMutation({
async mutationFn() {
return await request<'POST', { user: User }>(
'POST',
'/api/v1/login',
{
username,
password,
},
);
},
onSuccess() {
navigate({ to: '/' });
},
});
async function submit(ev: FormEvent<HTMLFormElement>) {
ev.preventDefault();
await loginMutation.mutateAsync();
}
return (
<Flex
justify="center"
align="center"
direction="column"
width="100vw"
height="100vh"
>
<Card size="2" variant="classic">
<form onSubmit={submit}>
<Flex gap="3" direction="column" position="relative">
<TextField.Root
placeholder="Username"
value={username}
onChange={(ev) =>
setUsername(ev.currentTarget.value)
}
>
<TextField.Slot>
<PersonIcon />
</TextField.Slot>
</TextField.Root>
<TextField.Root
placeholder="Password"
type="password"
value={password}
onChange={(ev) =>
setPassword(ev.currentTarget.value)
}
>
<TextField.Slot>
<LockClosedIcon />
</TextField.Slot>
</TextField.Root>
<Button type="submit" loading={loginMutation.isPending}>
Login
</Button>
</Flex>
</form>
</Card>
</Flex>
);
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import { tanstackRouter } from '@tanstack/router-plugin/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
}),
react(),
],
});

29
go.mod Normal file
View file

@ -0,0 +1,29 @@
module git.red-panda.pet/pandaware/house
go 1.23.2
require (
git.red-panda.pet/pandaware/lipgloss-catppuccin v0.0.0-20250608181442-a48744fcd663
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/google/uuid v1.6.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.28
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.39.0
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.33.0 // indirect
)

50
go.sum Normal file
View file

@ -0,0 +1,50 @@
git.red-panda.pet/pandaware/lipgloss-catppuccin v0.0.0-20250608181442-a48744fcd663 h1:jgxkCPBt+XdRvo1RPBcaGfF3X77hLg9SexLXZm2I2A0=
git.red-panda.pet/pandaware/lipgloss-catppuccin v0.0.0-20250608181442-a48744fcd663/go.mod h1:OsoRM6jK2N0aX3A3rDDGq28abo2b65uaIyb6ivtxoDI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=