diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf8280e --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# ── Node / Frontend ────────────────────────────────────────── +node_modules/ +frontend/dist/ +frontend/.env +frontend/.env.local +frontend/.env.*.local +*.tsbuildinfo + +# ── Go / Backend ───────────────────────────────────────────── +backend/bin/ +backend/*.exe +backend/*.test +*.out + +# ── Environment & Secrets ──────────────────────────────────── +.env +.env.* +!.env.example +infrastructure/.env +*.pem +*.key + +# ── Terraform ──────────────────────────────────────────────── +infrastructure/terraform/.terraform/ +infrastructure/terraform/.terraform.lock.hcl +infrastructure/terraform/terraform.tfstate +infrastructure/terraform/terraform.tfstate.backup +infrastructure/terraform/*.tfvars +infrastructure/terraform/override.tf +infrastructure/terraform/override.tf.json + +# ── Docker ─────────────────────────────────────────────────── +.docker/ + +# ── OS & Editor ────────────────────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# ── Logs & Temp ────────────────────────────────────────────── +*.log +logs/ +tmp/ +temp/ diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 9482c2e..de1b6a3 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -25,6 +25,7 @@ func main() { UserPasswords: make(map[string]string), FriendRequests: make(map[string]*models.FriendRequest), Locations: make(map[string]*models.Location), + Messages: make(map[string]*models.Message), } // Define our routes (Requires Go 1.22+ for method-based routing) @@ -49,6 +50,14 @@ func main() { mux.HandleFunc("POST /api/locations", api.EnableCORS(srv.HandleShareLocation)) mux.HandleFunc("GET /api/locations/nearby", api.EnableCORS(srv.HandleGetNearbyUsers)) + // Message routes + mux.HandleFunc("POST /api/messages", api.EnableCORS(srv.HandleSendMessage)) + mux.HandleFunc("GET /api/messages/conversations", api.EnableCORS(srv.HandleGetConversations)) + mux.HandleFunc("GET /api/messages/conversation", api.EnableCORS(srv.HandleGetConversation)) + + // Trending route + mux.HandleFunc("GET /api/trending", api.EnableCORS(srv.HandleGetTrending)) + // Start the server port := ":8080" log.Printf("Backend server starting on port %s...\n", port) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index e8faa2a..c7c65e1 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -18,6 +18,7 @@ type Server struct { UserPasswords map[string]string // In-memory password storage (demo only) FriendRequests map[string]*models.FriendRequest Locations map[string]*models.Location + Messages map[string]*models.Message } // HandleLogin authenticates a user diff --git a/backend/internal/api/messages.go b/backend/internal/api/messages.go new file mode 100644 index 0000000..8f6c4d8 --- /dev/null +++ b/backend/internal/api/messages.go @@ -0,0 +1,196 @@ +package api + +import ( + "encoding/json" + "net/http" + "sort" + "strings" + "time" + + "github.com/mtepenner/brevity-sharing/internal/models" +) + +// userByID looks up a user by their ID across the username-keyed Users map. +func (s *Server) userByID(id string) *models.User { + for _, u := range s.Users { + if u.ID == id { + return u + } + } + return nil +} + +// HandleSendMessage sends a private message from one user to another. +func (s *Server) HandleSendMessage(w http.ResponseWriter, r *http.Request) { + var req struct { + SenderID string `json:"sender_id"` + RecipientID string `json:"recipient_id"` + Content string `json:"content"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + if req.SenderID == "" || req.RecipientID == "" || req.Content == "" { + http.Error(w, "sender_id, recipient_id, and content are required", http.StatusBadRequest) + return + } + + if len(req.Content) > 1000 { + http.Error(w, "Message too long (max 1000 characters)", http.StatusBadRequest) + return + } + + if req.SenderID == req.RecipientID { + http.Error(w, "Cannot send a message to yourself", http.StatusBadRequest) + return + } + + msg := &models.Message{ + ID: generateID(), + SenderID: req.SenderID, + RecipientID: req.RecipientID, + Content: req.Content, + CreatedAt: time.Now(), + } + + s.Messages[msg.ID] = msg + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(msg) +} + +// Conversation summarises the last message and unread count for a conversation partner. +type Conversation struct { + PartnerID string `json:"partner_id"` + PartnerUsername string `json:"partner_username"` + LastMessage *models.Message `json:"last_message"` + UnreadCount int `json:"unread_count"` +} + +// HandleGetConversations returns a list of conversations for a given user. +func (s *Server) HandleGetConversations(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Query().Get("user_id") + if userID == "" { + http.Error(w, "user_id required", http.StatusBadRequest) + return + } + + convMap := make(map[string]*Conversation) + + for _, msg := range s.Messages { + if msg.SenderID != userID && msg.RecipientID != userID { + continue + } + + partnerID := msg.RecipientID + if msg.RecipientID == userID { + partnerID = msg.SenderID + } + + conv, exists := convMap[partnerID] + if !exists { + partnerUsername := partnerID + if partner := s.userByID(partnerID); partner != nil { + partnerUsername = partner.Username + } + conv = &Conversation{ + PartnerID: partnerID, + PartnerUsername: partnerUsername, + } + convMap[partnerID] = conv + } + + if conv.LastMessage == nil || msg.CreatedAt.After(conv.LastMessage.CreatedAt) { + msgCopy := *msg + conv.LastMessage = &msgCopy + } + + if msg.RecipientID == userID && msg.ReadAt == nil { + conv.UnreadCount++ + } + } + + conversations := make([]*Conversation, 0, len(convMap)) + for _, conv := range convMap { + conversations = append(conversations, conv) + } + + sort.Slice(conversations, func(i, j int) bool { + if conversations[i].LastMessage == nil { + return false + } + if conversations[j].LastMessage == nil { + return true + } + return conversations[i].LastMessage.CreatedAt.After(conversations[j].LastMessage.CreatedAt) + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(conversations) +} + +// HandleGetConversation returns all messages exchanged between two users. +func (s *Server) HandleGetConversation(w http.ResponseWriter, r *http.Request) { + user1 := r.URL.Query().Get("user1") + user2 := r.URL.Query().Get("user2") + + if user1 == "" || user2 == "" { + http.Error(w, "user1 and user2 required", http.StatusBadRequest) + return + } + + messages := make([]models.Message, 0) + for _, msg := range s.Messages { + if (msg.SenderID == user1 && msg.RecipientID == user2) || + (msg.SenderID == user2 && msg.RecipientID == user1) { + messages = append(messages, *msg) + } + } + + sort.Slice(messages, func(i, j int) bool { + return messages[i].CreatedAt.Before(messages[j].CreatedAt) + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) +} + +// HandleGetTrending returns the top trending hashtags extracted from stored tweets. +func (s *Server) HandleGetTrending(w http.ResponseWriter, r *http.Request) { + tweets := s.DB.GetTimeline() + + topicCounts := make(map[string]int) + for _, tweet := range tweets { + for _, word := range strings.Fields(tweet.Content) { + word = strings.Trim(word, ".,!?\"';:()") + if strings.HasPrefix(word, "#") && len(word) > 1 { + topicCounts[strings.ToLower(word)]++ + } + } + } + + type TrendingTopic struct { + Topic string `json:"topic"` + Count int `json:"count"` + } + + topics := make([]TrendingTopic, 0, len(topicCounts)) + for topic, count := range topicCounts { + topics = append(topics, TrendingTopic{Topic: topic, Count: count}) + } + + sort.Slice(topics, func(i, j int) bool { + return topics[i].Count > topics[j].Count + }) + + if len(topics) > 10 { + topics = topics[:10] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(topics) +} diff --git a/backend/internal/models/message.go b/backend/internal/models/message.go new file mode 100644 index 0000000..60e7d59 --- /dev/null +++ b/backend/internal/models/message.go @@ -0,0 +1,13 @@ +package models + +import "time" + +// Message represents a private message between two users +type Message struct { + ID string `json:"id"` + SenderID string `json:"sender_id"` + RecipientID string `json:"recipient_id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + ReadAt *time.Time `json:"read_at,omitempty"` +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8ee0c5..64e581e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,64 +1,107 @@ import React, { useState, useEffect } from 'react'; +import { ThemeProvider } from './context/ThemeContext'; +import { Sidebar, BottomNav } from './components/Nav'; import { Home } from './pages/Home'; +import { Explore } from './pages/Explore'; +import { Messages } from './pages/Messages'; +import { Notifications } from './pages/Notifications'; +import { Profile } from './pages/Profile'; +import { Settings } from './pages/Settings'; import { Auth } from './pages/Auth'; -import { User } from './types'; +import { User, Page } from './types'; const App: React.FC = () => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [currentPage, setCurrentPage] = useState('home'); - // Check if user is already logged in useEffect(() => { const token = localStorage.getItem('auth_token'); const storedUser = localStorage.getItem('user'); if (token && storedUser) { - setUser(JSON.parse(storedUser)); + try { + setUser(JSON.parse(storedUser)); + } catch { + localStorage.removeItem('user'); + localStorage.removeItem('auth_token'); + } } setLoading(false); }, []); - const handleLogin = (user: User) => { - setUser(user); - localStorage.setItem('user', JSON.stringify(user)); + const handleLogin = (loggedInUser: User) => { + setUser(loggedInUser); + localStorage.setItem('user', JSON.stringify(loggedInUser)); }; const handleLogout = () => { setUser(null); localStorage.removeItem('user'); localStorage.removeItem('auth_token'); + setCurrentPage('home'); }; if (loading) { return ( -
+
-

Loading...

+

Loading…

); } + const renderPage = () => { + if (!user) return null; + switch (currentPage) { + case 'home': + return ; + case 'explore': + return ; + case 'messages': + return ; + case 'notifications': + return ; + case 'profile': + return ; + case 'settings': + return ; + default: + return ; + } + }; + return ( -
+ {user ? ( - <> - {/* FUTURE: Left Sidebar Navigation - - */} +
+
+ {/* Sidebar – visible on sm+ */} + - {/* Main Content Area */} - + {/* Main content */} +
{renderPage()}
+
- {/* FUTURE: Right Sidebar (Trending, Who to follow) - - */} - + {/* Bottom nav – mobile only */} + +
) : ( )} -
+ ); }; export default App; + diff --git a/frontend/src/components/ComposePost.tsx b/frontend/src/components/ComposePost.tsx index 4afdc07..a66bd60 100644 --- a/frontend/src/components/ComposePost.tsx +++ b/frontend/src/components/ComposePost.tsx @@ -31,7 +31,7 @@ export const ComposePost: React.FC = ({ onPostCreated }) => { const charPercentage = (content.length / 280) * 100; return ( -
+
{/* Avatar */} @@ -42,7 +42,7 @@ export const ComposePost: React.FC = ({ onPostCreated }) => { {/* Compose Area */}