From d230c5a6e59810a96cb2862c844cc11574f7eb07 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 16 May 2026 14:49:14 +0300 Subject: [PATCH] discussions: per-repo discussion threads scaffold --- discussion.v | 131 ++++++++++++++++++++++++++++ discussion_routes.v | 160 ++++++++++++++++++++++++++++++++++ static/css/gitly.scss | 111 +++++++++++++++++++++++ templates/discussion.html | 80 +++++++++++++++++ templates/discussions.html | 59 +++++++++++++ templates/new/discussion.html | 65 ++++++++++++++ 6 files changed, 606 insertions(+) create mode 100644 discussion.v create mode 100644 discussion_routes.v create mode 100644 templates/discussion.html create mode 100644 templates/discussions.html create mode 100644 templates/new/discussion.html diff --git a/discussion.v b/discussion.v new file mode 100644 index 0000000..51087b4 --- /dev/null +++ b/discussion.v @@ -0,0 +1,131 @@ +// 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 Discussion { + id int @[primary; sql: serial] +mut: + repo_id int + author_id int + title string + body string + category string // general, qa, announcement, idea + is_locked bool + is_answered bool + answer_id int + comments_count int + created_at int +} + +struct DiscussionComment { + id int @[primary; sql: serial] +mut: + discussion_id int + author_id int + text string + created_at int +} + +fn (d &Discussion) relative_time() string { + return time.unix(d.created_at).relative() +} + +fn (d &Discussion) formatted_title() veb.RawHtml { + return html_escape_text(d.title) +} + +fn (d &Discussion) category_label() string { + return match d.category { + 'qa' { 'Q&A' } + 'announcement' { 'Announcement' } + 'idea' { 'Idea' } + else { 'General' } + } +} + +fn (c &DiscussionComment) relative() string { + return time.unix(c.created_at).relative() +} + +fn (mut app App) add_discussion(repo_id int, author_id int, title string, body string, category string) !int { + d := Discussion{ + repo_id: repo_id + author_id: author_id + title: title + body: body + category: category + created_at: int(time.now().unix()) + } + sql app.db { + insert d into Discussion + }! + return db_last_insert_id(app.db) +} + +fn (mut app App) find_discussion(id int) ?Discussion { + rows := sql app.db { + select from Discussion where id == id limit 1 + } or { []Discussion{} } + if rows.len == 0 { + return none + } + return rows.first() +} + +fn (mut app App) list_repo_discussions(repo_id int) []Discussion { + return sql app.db { + select from Discussion where repo_id == repo_id order by created_at desc + } or { []Discussion{} } +} + +fn (mut app App) add_discussion_comment(discussion_id int, author_id int, text string) ! { + c := DiscussionComment{ + discussion_id: discussion_id + author_id: author_id + text: text + created_at: int(time.now().unix()) + } + sql app.db { + insert c into DiscussionComment + }! + sql app.db { + update Discussion set comments_count = comments_count + 1 where id == discussion_id + }! +} + +fn (mut app App) get_discussion_comments(discussion_id int) []DiscussionComment { + return sql app.db { + select from DiscussionComment where discussion_id == discussion_id order by created_at + } or { []DiscussionComment{} } +} + +fn (mut app App) set_discussion_lock(discussion_id int, locked bool) ! { + sql app.db { + update Discussion set is_locked = locked where id == discussion_id + }! +} + +fn (mut app App) mark_discussion_answer(discussion_id int, comment_id int) ! { + sql app.db { + update Discussion set is_answered = true, answer_id = comment_id where id == discussion_id + }! +} + +fn (mut app App) delete_discussion(id int) ! { + sql app.db { + delete from DiscussionComment where discussion_id == id + }! + sql app.db { + delete from Discussion where id == id + }! +} + +fn (mut app App) delete_repo_discussions(repo_id int) ! { + ds := app.list_repo_discussions(repo_id) + for d in ds { + app.delete_discussion(d.id) or {} + } +} diff --git a/discussion_routes.v b/discussion_routes.v new file mode 100644 index 0000000..0ec2d92 --- /dev/null +++ b/discussion_routes.v @@ -0,0 +1,160 @@ +// 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 DiscussionWithUser { + item Discussion + user User +} + +struct DiscussionCommentWithUser { + item DiscussionComment + user User +} + +@['/:username/:repo_name/discussions'] +pub fn (mut app App) handle_get_repo_discussions(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() + } + discussions := app.list_repo_discussions(repo.id) + mut rows := []DiscussionWithUser{} + for d in discussions { + u := app.get_user_by_id(d.author_id) or { continue } + rows << DiscussionWithUser{ + item: d + user: u + } + } + return $veb.html('templates/discussions.html') +} + +@['/:username/:repo_name/discussions/new'] +pub fn (mut app App) new_discussion(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 !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public { + return ctx.not_found() + } + return $veb.html('templates/new/discussion.html') +} + +@['/:username/:repo_name/discussions'; post] +pub fn (mut app App) handle_create_discussion(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() } + title := ctx.form['title'] + body := ctx.form['body'] + category := ctx.form['category'] + if validation.is_string_empty(title) { + return ctx.redirect('/${username}/${repo_name}/discussions/new') + } + cat := if category in ['general', 'qa', 'announcement', 'idea'] { category } else { 'general' } + id := app.add_discussion(repo.id, ctx.user.id, title, body, cat) or { + ctx.error('Could not create discussion') + return ctx.redirect('/${username}/${repo_name}/discussions/new') + } + return ctx.redirect('/${username}/${repo_name}/discussions/${id}') +} + +@['/:username/:repo_name/discussions/:id'] +pub fn (mut app App) view_discussion(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() + } + discussion := app.find_discussion(id.int()) or { return ctx.not_found() } + if discussion.repo_id != repo.id { + return ctx.not_found() + } + author := app.get_user_by_id(discussion.author_id) or { return ctx.not_found() } + raw_comments := app.get_discussion_comments(discussion.id) + mut comments := []DiscussionCommentWithUser{} + for c in raw_comments { + u := app.get_user_by_id(c.author_id) or { continue } + comments << DiscussionCommentWithUser{ + item: c + user: u + } + } + is_owner := ctx.logged_in + && (repo.user_id == ctx.user.id || discussion.author_id == ctx.user.id) + return $veb.html('templates/discussion.html') +} + +@['/:username/:repo_name/discussions/:id/comments'; post] +pub fn (mut app App) handle_add_discussion_comment(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() } + discussion := app.find_discussion(id.int()) or { return ctx.not_found() } + if discussion.repo_id != repo.id || discussion.is_locked { + return ctx.redirect('/${username}/${repo_name}/discussions/${id}') + } + text := ctx.form['text'] + if validation.is_string_empty(text) { + return ctx.redirect('/${username}/${repo_name}/discussions/${id}') + } + app.add_discussion_comment(discussion.id, ctx.user.id, text) or {} + return ctx.redirect('/${username}/${repo_name}/discussions/${id}') +} + +@['/:username/:repo_name/discussions/:id/lock'; post] +pub fn (mut app App) handle_lock_discussion(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() } + discussion := app.find_discussion(id.int()) or { return ctx.not_found() } + if discussion.repo_id != repo.id { + return ctx.not_found() + } + if repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/discussions/${id}') + } + app.set_discussion_lock(discussion.id, !discussion.is_locked) or {} + return ctx.redirect('/${username}/${repo_name}/discussions/${id}') +} + +@['/:username/:repo_name/discussions/:id/delete'; post] +pub fn (mut app App) handle_delete_discussion(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() } + discussion := app.find_discussion(id.int()) or { return ctx.not_found() } + if discussion.repo_id != repo.id { + return ctx.not_found() + } + if repo.user_id != ctx.user.id && discussion.author_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/discussions/${id}') + } + app.delete_discussion(discussion.id) or {} + return ctx.redirect('/${username}/${repo_name}/discussions') +} + +@['/:username/:repo_name/discussions/:id/answer/:cid'; post] +pub fn (mut app App) handle_mark_answer(mut ctx Context, username string, repo_name string, id string, cid 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() } + discussion := app.find_discussion(id.int()) or { return ctx.not_found() } + if discussion.repo_id != repo.id { + return ctx.not_found() + } + if discussion.author_id != ctx.user.id && repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/discussions/${id}') + } + app.mark_discussion_answer(discussion.id, cid.int()) or {} + return ctx.redirect('/${username}/${repo_name}/discussions/${id}') +} diff --git a/static/css/gitly.scss b/static/css/gitly.scss index 274ec48..35882bd 100644 --- a/static/css/gitly.scss +++ b/static/css/gitly.scss @@ -956,6 +956,117 @@ form { } } +.new-discussion { + max-width: 720px; +} + +.new-discussion__header { + margin: 24px 0 20px; + + h1 { + font-size: 24px; + margin: 0; + padding: 0; + } +} + +.new-discussion__form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.new-discussion__field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.new-discussion__label { + font-weight: 600; + font-size: 13px; + margin: 0; + padding: 0; + color: $black; +} + +.new-discussion__req { + color: #cf222e; + margin-left: 2px; +} + +.new-discussion__title-input { + box-sizing: border-box; + width: 100%; + padding: 8px 12px; + font-size: 15px; +} + +.new-discussion__textarea { + box-sizing: border-box; + width: 100%; + padding: 10px 12px; + font-size: 14px; + font-family: inherit; + line-height: 1.5; + min-height: 180px; + resize: vertical; +} + +.new-discussion__option { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border: 1px solid $gray; + border-radius: $small-radius; + cursor: pointer; + transition: border-color 0.07s, background-color 0.07s; + margin-top: 6px; + + &:hover { + border-color: #b8c7d8; + background-color: #fafbfc; + } + + input[type="radio"] { + margin: 0; + } +} + +.new-discussion__option-body { + display: flex; + flex-direction: column; + gap: 2px; +} + +.new-discussion__option-title { + font-weight: 600; + font-size: 13px; + color: $black; +} + +.new-discussion__actions { + padding-top: 6px; + display: flex; + justify-content: flex-start; +} + +.new-discussion__submit { + width: auto !important; + padding: 7px 18px; + background-color: #1f883d; + color: $white; + border: 1px solid rgba(31, 136, 61, 0.6); + font-weight: 500; + + &:hover { + background-color: #1a7f37; + border-color: rgba(27, 31, 36, 0.15); + color: $white; + } +} + .issues-header { display: flex; align-items: center; diff --git a/templates/discussion.html b/templates/discussion.html new file mode 100644 index 0000000..203a679 --- /dev/null +++ b/templates/discussion.html @@ -0,0 +1,80 @@ + + + + @include 'layout/head.html' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +
+

@{discussion.formatted_title()} #@discussion.id

+
+ @{discussion.category_label()} + @if discussion.is_answered + %discussion_answered + @end + @if discussion.is_locked + %discussion_locked + @end + %discussion_opened @{discussion.relative_time()} %pr_by + @author.username +
+
+ +
+ +
+ + @for c in comments + + @end + + @if ctx.logged_in && !discussion.is_locked +
+ + +
+ @end + + @if ctx.logged_in && is_owner +
+
+ @if discussion.is_locked + + @else + + @end +
+
+ +
+
+ @end +
+ + @include 'layout/footer.html' + + diff --git a/templates/discussions.html b/templates/discussions.html new file mode 100644 index 0000000..4101b1b --- /dev/null +++ b/templates/discussions.html @@ -0,0 +1,59 @@ + + + + @include 'layout/head.html' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +
+

%discussions

+ @if ctx.logged_in + %discussion_new + @end +
+ + @if rows.len == 0 +
%discussion_none
+ @else + + @end +
+ + @include 'layout/footer.html' + + diff --git a/templates/new/discussion.html b/templates/new/discussion.html new file mode 100644 index 0000000..f435a9e --- /dev/null +++ b/templates/new/discussion.html @@ -0,0 +1,65 @@ + + + + @include '../layout/head.html' + + + @include '../layout/header.html' + +
+ @include '../layout/repo_menu.html' + +
+
+

%discussion_new

+
+ +
+
+ + +
+ +
+

%discussion_category

+ + + + +
+ +
+ + +
+ +
+ +
+
+
+
+ + @include '../layout/footer.html' + + -- 2.39.5