From e5cf6929b726be8619b74a869c136696dfea4546 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 16 May 2026 14:49:26 +0300 Subject: [PATCH] projects: kanban-style project boards scaffold --- project.v | 176 +++++++++++++++++++++++++++++++++++++ project_routes.v | 175 ++++++++++++++++++++++++++++++++++++ templates/new/project.html | 27 ++++++ templates/project.html | 91 +++++++++++++++++++ templates/projects.html | 35 ++++++++ 5 files changed, 504 insertions(+) create mode 100644 project.v create mode 100644 project_routes.v create mode 100644 templates/new/project.html create mode 100644 templates/project.html create mode 100644 templates/projects.html diff --git a/project.v b/project.v new file mode 100644 index 0000000..1cb244e --- /dev/null +++ b/project.v @@ -0,0 +1,176 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import time +import veb + +struct Project { + id int @[primary; sql: serial] +mut: + repo_id int + name string + description string + created_at int +} + +struct ProjectColumn { + id int @[primary; sql: serial] +mut: + project_id int + name string + position int +} + +struct ProjectCard { + id int @[primary; sql: serial] +mut: + column_id int + title string + note string + position int + issue_id int // 0 if a free-form note + created_at int +} + +fn (p &Project) formatted_name() veb.RawHtml { + return html_escape_text(p.name) +} + +fn (mut app App) add_project(repo_id int, name string, description string) !int { + pr := Project{ + repo_id: repo_id + name: name + description: description + created_at: int(time.now().unix()) + } + sql app.db { + insert pr into Project + }! + project_id := db_last_insert_id(app.db) + if project_id != 0 { + for i, col_name in ['Todo', 'In progress', 'Done'] { + app.add_project_column(project_id, col_name, i) or {} + } + } + return project_id +} + +fn (mut app App) list_repo_projects(repo_id int) []Project { + return sql app.db { + select from Project where repo_id == repo_id order by id desc + } or { []Project{} } +} + +fn (mut app App) find_project(id int) ?Project { + rows := sql app.db { + select from Project where id == id limit 1 + } or { []Project{} } + if rows.len == 0 { + return none + } + return rows.first() +} + +fn (mut app App) delete_project(id int) ! { + cols := app.list_project_columns(id) + for col in cols { + sql app.db { + delete from ProjectCard where column_id == col.id + }! + } + sql app.db { + delete from ProjectColumn where project_id == id + }! + sql app.db { + delete from Project where id == id + }! +} + +fn (mut app App) delete_repo_projects(repo_id int) ! { + prs := app.list_repo_projects(repo_id) + for pr in prs { + app.delete_project(pr.id) or {} + } +} + +fn (mut app App) add_project_column(project_id int, name string, position int) !int { + c := ProjectColumn{ + project_id: project_id + name: name + position: position + } + sql app.db { + insert c into ProjectColumn + }! + return db_last_insert_id(app.db) +} + +fn (mut app App) list_project_columns(project_id int) []ProjectColumn { + return sql app.db { + select from ProjectColumn where project_id == project_id order by position + } or { []ProjectColumn{} } +} + +fn (mut app App) find_project_column(id int) ?ProjectColumn { + rows := sql app.db { + select from ProjectColumn where id == id limit 1 + } or { []ProjectColumn{} } + if rows.len == 0 { + return none + } + return rows.first() +} + +fn (mut app App) delete_project_column(id int) ! { + sql app.db { + delete from ProjectCard where column_id == id + }! + sql app.db { + delete from ProjectColumn where id == id + }! +} + +fn (mut app App) add_project_card(column_id int, title string, note string) ! { + pos := sql app.db { + select count from ProjectCard where column_id == column_id + } or { 0 } + c := ProjectCard{ + column_id: column_id + title: title + note: note + position: pos + created_at: int(time.now().unix()) + } + sql app.db { + insert c into ProjectCard + }! +} + +fn (mut app App) list_project_cards(column_id int) []ProjectCard { + return sql app.db { + select from ProjectCard where column_id == column_id order by position + } or { []ProjectCard{} } +} + +fn (mut app App) find_project_card(id int) ?ProjectCard { + rows := sql app.db { + select from ProjectCard where id == id limit 1 + } or { []ProjectCard{} } + if rows.len == 0 { + return none + } + return rows.first() +} + +fn (mut app App) move_project_card(card_id int, new_column_id int) ! { + sql app.db { + update ProjectCard set column_id = new_column_id where id == card_id + }! +} + +fn (mut app App) delete_project_card(id int) ! { + sql app.db { + delete from ProjectCard where id == id + }! +} diff --git a/project_routes.v b/project_routes.v new file mode 100644 index 0000000..619909d --- /dev/null +++ b/project_routes.v @@ -0,0 +1,175 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import veb +import validation + +struct ProjectColumnView { + column ProjectColumn + cards []ProjectCard +} + +@['/:username/:repo_name/projects'] +pub fn (mut app App) handle_get_repo_projects(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public { + return ctx.not_found() + } + projects := app.list_repo_projects(repo.id) + return $veb.html('templates/projects.html') +} + +@['/:username/:repo_name/projects/new'] +pub fn (mut app App) new_project(mut ctx Context, username string, repo_name string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/projects') + } + return $veb.html('templates/new/project.html') +} + +@['/:username/:repo_name/projects'; post] +pub fn (mut app App) handle_create_project(mut ctx Context, username string, repo_name string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/projects') + } + name := ctx.form['name'] + desc := ctx.form['description'] + if validation.is_string_empty(name) { + return ctx.redirect('/${username}/${repo_name}/projects/new') + } + id := app.add_project(repo.id, name, desc) or { + ctx.error('Could not create project') + return ctx.redirect('/${username}/${repo_name}/projects/new') + } + return ctx.redirect('/${username}/${repo_name}/projects/${id}') +} + +@['/:username/:repo_name/projects/:id'] +pub fn (mut app App) view_project(mut ctx Context, username string, repo_name string, id string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public { + return ctx.not_found() + } + project := app.find_project(id.int()) or { return ctx.not_found() } + if project.repo_id != repo.id { + return ctx.not_found() + } + columns := app.list_project_columns(project.id) + mut views := []ProjectColumnView{} + for col in columns { + views << ProjectColumnView{ + column: col + cards: app.list_project_cards(col.id) + } + } + can_edit := ctx.logged_in && repo.user_id == ctx.user.id + return $veb.html('templates/project.html') +} + +@['/:username/:repo_name/projects/:id/columns'; post] +pub fn (mut app App) handle_add_project_column(mut ctx Context, username string, repo_name string, id string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + project := app.find_project(id.int()) or { return ctx.not_found() } + if project.repo_id != repo.id || repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/projects/${id}') + } + name := ctx.form['name'] + if validation.is_string_empty(name) { + return ctx.redirect('/${username}/${repo_name}/projects/${id}') + } + pos := app.list_project_columns(project.id).len + app.add_project_column(project.id, name, pos) or {} + return ctx.redirect('/${username}/${repo_name}/projects/${id}') +} + +@['/:username/:repo_name/projects/:id/columns/:col_id/delete'; post] +pub fn (mut app App) handle_delete_project_column(mut ctx Context, username string, repo_name string, id string, col_id string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + project := app.find_project(id.int()) or { return ctx.not_found() } + if project.repo_id != repo.id || repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/projects/${id}') + } + app.delete_project_column(col_id.int()) or {} + return ctx.redirect('/${username}/${repo_name}/projects/${id}') +} + +@['/:username/:repo_name/projects/:id/columns/:col_id/cards'; post] +pub fn (mut app App) handle_add_project_card(mut ctx Context, username string, repo_name string, id string, col_id string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + project := app.find_project(id.int()) or { return ctx.not_found() } + if project.repo_id != repo.id || repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/projects/${id}') + } + col := app.find_project_column(col_id.int()) or { return ctx.not_found() } + if col.project_id != project.id { + return ctx.not_found() + } + title := ctx.form['title'] + note := ctx.form['note'] + if validation.is_string_empty(title) { + return ctx.redirect('/${username}/${repo_name}/projects/${id}') + } + app.add_project_card(col.id, title, note) or {} + return ctx.redirect('/${username}/${repo_name}/projects/${id}') +} + +@['/:username/:repo_name/projects/:id/cards/:card_id/delete'; post] +pub fn (mut app App) handle_delete_project_card(mut ctx Context, username string, repo_name string, id string, card_id string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + project := app.find_project(id.int()) or { return ctx.not_found() } + if project.repo_id != repo.id || repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/projects/${id}') + } + app.delete_project_card(card_id.int()) or {} + return ctx.redirect('/${username}/${repo_name}/projects/${id}') +} + +@['/:username/:repo_name/projects/:id/cards/:card_id/move'; post] +pub fn (mut app App) handle_move_project_card(mut ctx Context, username string, repo_name string, id string, card_id string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + project := app.find_project(id.int()) or { return ctx.not_found() } + if project.repo_id != repo.id || repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/projects/${id}') + } + new_col := ctx.form['column_id'].int() + app.move_project_card(card_id.int(), new_col) or {} + return ctx.redirect('/${username}/${repo_name}/projects/${id}') +} + +@['/:username/:repo_name/projects/:id/delete'; post] +pub fn (mut app App) handle_delete_project(mut ctx Context, username string, repo_name string, id string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + project := app.find_project(id.int()) or { return ctx.not_found() } + if project.repo_id != repo.id || repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/projects/${id}') + } + app.delete_project(project.id) or {} + return ctx.redirect('/${username}/${repo_name}/projects') +} diff --git a/templates/new/project.html b/templates/new/project.html new file mode 100644 index 0000000..7d74877 --- /dev/null +++ b/templates/new/project.html @@ -0,0 +1,27 @@ + + + + @include '../layout/head.html' + + + @include '../layout/header.html' + +
+ @include '../layout/repo_menu.html' + +

%project_new

+ +
+
+ +
+
+ +
+ +
+
+ + @include '../layout/footer.html' + + diff --git a/templates/project.html b/templates/project.html new file mode 100644 index 0000000..781557e --- /dev/null +++ b/templates/project.html @@ -0,0 +1,91 @@ + + + + @include 'layout/head.html' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +
+

@{project.formatted_name()}

+ @if can_edit +
+ +
+ @end +
+ + @if project.description != '' +

@project.description

+ @end + +
+ @for cv in views +
+
+

@cv.column.name

+ @if can_edit +
+ +
+ @end +
+
    + @if cv.cards.len == 0 +
  • %project_column_empty
  • + @end + @for card in cv.cards +
  • +
    @card.title
    + @if card.note != '' +

    @card.note

    + @end + @if can_edit +
    +
    + + +
    +
    + +
    +
    + @end +
  • + @end +
+ @if can_edit +
+ + + +
+ @end +
+ @end + + @if can_edit +
+
+ + +
+
+ @end +
+
+ + @include 'layout/footer.html' + + diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..f862a30 --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,35 @@ + + + + @include 'layout/head.html' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +
+

%projects

+ @if ctx.logged_in && repo.user_id == ctx.user.id + %project_new + @end +
+ + @if projects.len == 0 +
%project_none
+ @else + + @end +
+ + @include 'layout/footer.html' + + -- 2.39.5