From 091e55fc2fc65e549de46566a2baeb6f735534ca Mon Sep 17 00:00:00 2001 From: Mason Payne Date: Sun, 1 Dec 2024 23:43:52 -0700 Subject: [PATCH] add vote infra and ui prep for connection ui to backend --- backend/datastore/mapper.go | 91 ++++++++++++++++++++++++-- backend/main.go | 1 + backend/migrations/0001-init.sql | 3 +- backend/server/server.go | 19 +++++- backend/types/types.go | 4 ++ ui/package-lock.json | 12 ++++ ui/package.json | 1 + ui/src/assets/main.css | 8 ++- ui/src/components/CommentComponent.vue | 60 ++++++++++++++--- ui/src/types/Remark.ts | 12 ++++ ui/src/views/RemarkView.vue | 17 ++--- 11 files changed, 197 insertions(+), 31 deletions(-) create mode 100644 ui/src/types/Remark.ts diff --git a/backend/datastore/mapper.go b/backend/datastore/mapper.go index 75098ab..c883d8d 100644 --- a/backend/datastore/mapper.go +++ b/backend/datastore/mapper.go @@ -2,16 +2,20 @@ package datastore import ( "database/sql" + "errors" "fmt" "git.sa.vin/any-remark/backend/types" "math/rand" + "strings" ) type Mapper interface { GetOpenGraphData(url string) (string, error) SaveOpenGraphData(url, opengraph string) error AddComment(req types.AddCommentRequest) error - GetComments(url string) ([]types.Comment, error) + GetComments(url string, orderBy string) ([]types.Comment, error) + AddVote(commentID, userID, voteType string) error + GetWebpageIDFromCommentID(commentID string) (string, error) } type mapper struct { @@ -103,21 +107,31 @@ func (m *mapper) AddComment(req types.AddCommentRequest) error { return nil } -func (m *mapper) GetComments(url string) ([]types.Comment, error) { +func (m *mapper) GetComments(url string, orderBy string) ([]types.Comment, error) { webpageID, err := m.GetWebpageID(url) if err != nil { return nil, fmt.Errorf("error getting webpage id: %s", err) } - // TODO: add up vote and down vote counts - // TODO: attach avatar url to commenter // TODO: provide order by recent or most upvoted // TODO: bubble up the current user's comments // get the comments from the db table named "comments" using the webpage_id - query := `SELECT id, content, commenter FROM comments WHERE webpage_id = ?` + query := `SELECT c.id, c.content, c.commenter, u.username, u.avatar, c.created_at, + COUNT(CASE WHEN v.vote_type = 'up' THEN 1 END) AS upvotes, + COUNT(CASE WHEN v.vote_type = 'down' THEN 1 END) AS downvotes + FROM comments c + JOIN users u ON c.commenter = u.id + LEFT JOIN votes v ON c.id = v.comment_id + WHERE c.webpage_id = ? + GROUP BY c.id, c.content, c.commenter, u.username, u.avatar` + if orderBy == "upvotes" { + query += " ORDER BY upvotes DESC" + } else { + query += " ORDER BY c.created_at DESC" + } rows, err := m.db.Query(query, webpageID) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("no data found in db for url: %s", url) } return nil, fmt.Errorf("error getting comments from db: %s", err) @@ -128,7 +142,7 @@ func (m *mapper) GetComments(url string) ([]types.Comment, error) { var comments []types.Comment for rows.Next() { var comment types.Comment - err = rows.Scan(&comment.ID, &comment.Content, &comment.Commenter) + err = rows.Scan(&comment.ID, &comment.Content, &comment.Commenter, &comment.Username, &comment.Avatar, &comment.CreatedAt, &comment.Upvotes, &comment.Downvotes) if err != nil { return nil, fmt.Errorf("error scanning comments: %s", err) } @@ -137,6 +151,69 @@ func (m *mapper) GetComments(url string) ([]types.Comment, error) { return comments, nil } +func (m *mapper) AddVote(commentID, userID, voteType string) error { + voteType = strings.ToLower(voteType) + if voteType != "up" && voteType != "down" { + return fmt.Errorf("invalid vote type: %s", voteType) + } + + id := "v_" + generateID(24) + var err error + + webpageID := "" + if strings.HasPrefix(commentID, "wp_") { + webpageID = commentID + commentID = "" + } + + if webpageID == "" { + webpageID, err = m.GetWebpageIDFromCommentID(commentID) + if err != nil { + return fmt.Errorf("error getting webpage id from comment id: %s", err) + } + } + + // insert the vote data to the db table named "votes" using the comment_id + query := `INSERT INTO votes (id, webpage_id, comment_id, voter, vote_type) VALUES (?, ?, ?, ?, ?)` + _, err = m.db.Exec(query, id, webpageID, commentID, userID, voteType) + if err != nil { + // if the vote already exists, update the vote + if !strings.Contains(err.Error(), "UNIQUE constraint") { + return fmt.Errorf("error inserting vote data to db: %s", err) + } + query = `UPDATE votes SET vote_type = ? WHERE voter = ? AND comment_id = ? AND webpage_id = ?` + _, err = m.db.Exec(query, voteType, userID, commentID, webpageID) + if err != nil { + return fmt.Errorf("error updating vote data in db: %s", err) + } + } + return nil +} + +func (m *mapper) GetWebpageIDFromCommentID(commentID string) (string, error) { + // get the id of the webpage from the db table named "comments" using the comment_id + query := `SELECT webpage_id FROM comments WHERE id = ?` + rows, err := m.db.Query(query, commentID) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("no data found in db for comment_id: %s", commentID) + } + return "", fmt.Errorf("error getting webpage id from db: %s", err) + } + defer rows.Close() + + // get the id from the rows array + var webpageID string + for rows.Next() { + err = rows.Scan(&webpageID) + if err != nil { + return "", fmt.Errorf("error scanning webpage id: %s", err) + } + return webpageID, nil + } + return "", fmt.Errorf("no data found in db for comment_id: %s", commentID) +} + func generateID(n int) string { // generate a unique id for the webpage make it 24 bytes // long using the following character set diff --git a/backend/main.go b/backend/main.go index c521c52..073b19c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -57,6 +57,7 @@ func main() { r.HandleFunc("/api/v1/shorten", s.ShortenLinkHandler).Methods("POST") r.HandleFunc("/api/v1/comments", s.ListComments).Methods("GET") r.HandleFunc("/api/v1/comment", s.AddComment).Methods("POST") + r.HandleFunc("/api/v1/vote", s.AddVote).Methods("POST") r.HandleFunc("/x/{id}", s.ShortLinkHandler).Methods("GET") c := cors.New(cors.Options{ diff --git a/backend/migrations/0001-init.sql b/backend/migrations/0001-init.sql index 08411df..3d08716 100644 --- a/backend/migrations/0001-init.sql +++ b/backend/migrations/0001-init.sql @@ -26,7 +26,8 @@ CREATE TABLE votes ( comment_id TEXT, -- NULL if vote is on webpage voter TEXT NOT NULL, vote_type TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (voter, comment_id, webpage_id) -- ensure a user can only vote once per comment or webpage ); -- make a table for storing users diff --git a/backend/server/server.go b/backend/server/server.go index 31eba4f..ce6a542 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -15,6 +15,7 @@ type Server interface { ShortenLinkHandler(w http.ResponseWriter, r *http.Request) AddComment(w http.ResponseWriter, r *http.Request) ListComments(w http.ResponseWriter, r *http.Request) + AddVote(w http.ResponseWriter, r *http.Request) } type server struct { @@ -109,7 +110,11 @@ func (s *server) AddComment(w http.ResponseWriter, r *http.Request) { } func (s *server) ListComments(w http.ResponseWriter, r *http.Request) { - comments, err := s.mapper.GetComments(r.URL.Query().Get("url")) + orderBy := "created_at" // can be "upvotes" or "created_at" + if r.URL.Query().Get("order_by") != "" { + orderBy = r.URL.Query().Get("order_by") + } + comments, err := s.mapper.GetComments(r.URL.Query().Get("url"), orderBy) if err != nil { http.Error(w, fmt.Sprintf("error getting comments: %s", err), http.StatusInternalServerError) return @@ -126,6 +131,18 @@ func (s *server) ListComments(w http.ResponseWriter, r *http.Request) { } } +func (s *server) AddVote(w http.ResponseWriter, r *http.Request) { + + // TODO: get userID from the authentication token + userID := "u_12345" + + err := s.mapper.AddVote(r.URL.Query().Get("id"), userID, r.URL.Query().Get("vote")) + if err != nil { + http.Error(w, fmt.Sprintf("error adding vote: %s", err), http.StatusInternalServerError) + return + } +} + // CorsMiddleware is a middleware that allows CORS func CorsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/types/types.go b/backend/types/types.go index e423eff..27d796e 100644 --- a/backend/types/types.go +++ b/backend/types/types.go @@ -10,6 +10,10 @@ type Comment struct { ID string `json:"id"` Content string `json:"content"` Commenter string `json:"commenter"` + Username string `json:"username"` + Avatar string `json:"avatar"` + Upvotes int `json:"upvotes"` + Downvotes int `json:"downvotes"` ParentID string `json:"parent_id"` WebpageID string `json:"webpage_id"` CreatedAt string `json:"created_at"` diff --git a/ui/package-lock.json b/ui/package-lock.json index e8b8fb1..25bc099 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,6 +12,7 @@ "@capacitor/cli": "^6.2.0", "@capacitor/core": "^6.2.0", "@capacitor/ios": "^6.2.0", + "@iconify-prerendered/vue-material-symbols": "^0.28.1732258297", "pinia": "^2.2.6", "vue": "^3.5.12", "vue-router": "^4.4.5" @@ -1160,6 +1161,17 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify-prerendered/vue-material-symbols": { + "version": "0.28.1732258297", + "resolved": "https://registry.npmjs.org/@iconify-prerendered/vue-material-symbols/-/vue-material-symbols-0.28.1732258297.tgz", + "integrity": "sha512-PFe4eKDuxbXwNfJk0DLWoWkI/CxDMQ1tFh2O5EmYuFVCZeG4janePeShlsPAxyW9+hhthptsaEJLwIGrvTA5Rw==", + "funding": { + "url": "https://www.buymeacoffee.com/kozack/" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/@ionic/cli-framework-output": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", diff --git a/ui/package.json b/ui/package.json index d7f6d29..ebd6043 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,6 +19,7 @@ "@capacitor/cli": "^6.2.0", "@capacitor/core": "^6.2.0", "@capacitor/ios": "^6.2.0", + "@iconify-prerendered/vue-material-symbols": "^0.28.1732258297", "pinia": "^2.2.6", "vue": "^3.5.12", "vue-router": "^4.4.5" diff --git a/ui/src/assets/main.css b/ui/src/assets/main.css index 9298758..971c759 100644 --- a/ui/src/assets/main.css +++ b/ui/src/assets/main.css @@ -7,8 +7,12 @@ font-weight: normal; } -a, -.green { +h4 { + font-size: 0.8rem; + font-weight: bold; +} + +a, .green { text-decoration: none; color: hsla(160, 100%, 37%, 1); transition: 0.4s; diff --git a/ui/src/components/CommentComponent.vue b/ui/src/components/CommentComponent.vue index 78ef2cc..4855404 100644 --- a/ui/src/components/CommentComponent.vue +++ b/ui/src/components/CommentComponent.vue @@ -1,24 +1,52 @@ @@ -41,5 +69,21 @@ const props = defineProps({ padding: 10px 15px; border-radius: 8px; flex-grow: 1; + display: flex; + flex-direction: column; +} + +.comment-actions { + display: flex; + justify-content: flex-start; +} + +/* remove default button styles */ +.comment-actions button { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; } diff --git a/ui/src/types/Remark.ts b/ui/src/types/Remark.ts new file mode 100644 index 0000000..4c5ae7f --- /dev/null +++ b/ui/src/types/Remark.ts @@ -0,0 +1,12 @@ +export interface Remark { + id: string; + content: string; + commenter: string; + username: string; + avatar: string; + upvotes: number; + downvotes: number; + parent_id: string; + webpage_id: string; + created_at: string; +} diff --git a/ui/src/views/RemarkView.vue b/ui/src/views/RemarkView.vue index a6ac289..c0d0b56 100644 --- a/ui/src/views/RemarkView.vue +++ b/ui/src/views/RemarkView.vue @@ -3,22 +3,14 @@ import CommentComponent from '@/components/CommentComponent.vue' import OpenGraphComponent from '@/components/OpenGraphComponent.vue' import { onMounted, ref, watch } from 'vue' +import type { Remark } from '@/types/Remark' const BACKEND_URL = 'http://localhost:8080'; -interface Comment { - id: string; - content: string; - commenter: string; - parent_id: string; - webpage_id: string; - created_at: string; -} - // const url = ref('https://www.youtube.com/watch?v=iedMwhLrFQQ'); // const url = ref('https://vimeo.com/867950660'); const url = ref(''); const comment = ref(''); -const comments = ref([]); +const comments = ref([]); onMounted(() => { // get target url from query param 'url' @@ -41,8 +33,9 @@ function getComments() { .then((data) => { if (!!data) { comments.value = data; + } else { + comments.value = []; } - console.log(data); }); } @@ -88,7 +81,7 @@ function handleSubmit(e: Event) {
- +