Compare commits
13 commits
b0f919f9ff
...
8d1ef4e915
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1ef4e915 | ||
|
|
96aafce53f | ||
|
|
f7763199ff | ||
|
|
d520ec4c86 | ||
|
|
1d0464c219 | ||
|
|
bf2f52da81 | ||
|
|
ac755ab7b0 | ||
|
|
f91bc1b826 | ||
|
|
aa1e630809 | ||
|
|
e83d3de8c3 | ||
|
|
e683398364 | ||
|
|
5d27045819 | ||
|
|
6bb0af937a |
6 changed files with 536 additions and 173 deletions
66
AUDIT_REPORT_MAXIMUM_UTILITY.md
Normal file
66
AUDIT_REPORT_MAXIMUM_UTILITY.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# 📊 Elmeg "Maximum Utility" Audit & Upgrade Plan
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
The application has solid foundational data structures (Shows, Songs, Venues, Users) and initial social features (Ratings, Reviews). However, the **Presentation Layer** relies heavily on text lists and static tables. To achieve "Maximum Utility," we need to visualize this data to reveal trends, and add "Visual Delight" to encourage interaction.
|
||||
|
||||
## 2. Identified Gaps & Opportunities
|
||||
|
||||
### A. Data Visualization (Charts & Graphs)
|
||||
|
||||
*Current State:* Numeric averages and counts.
|
||||
*Upgrade Potential:*
|
||||
|
||||
1. **Song Evolution Graph (Song Detail):** A scatter plot showing Rating vs. Date. visualizes if a song is getting better or worse over time ("The 2024 Peak").
|
||||
2. **User Attendance Heatmap (Profile):** A GitHub-style commit calendar showing show attendance frequency over the year.
|
||||
3. **Rating Distribution (Show Detail):** A bar chart showing the breakdown of ratings (How many 5s? How many 1s?) to see if a show is "divisive" or "universally loved".
|
||||
4. **Venue Map (Venue Detail):** A visual map indicating location.
|
||||
|
||||
### B. Visual Delight (SVG Animations)
|
||||
|
||||
*Current State:* Standard browser interactions.
|
||||
*Upgrade Potential:*
|
||||
|
||||
1. **"Heady" Rating Interaction:** When a user rates a show 5 stars, trigger a confetti or "fire" SVG animation.
|
||||
2. **Page Transitions:** Smooth cross-fade or slide when navigating between tabs (Tabs are currently instant/harsh).
|
||||
3. **Loading Skeletons:** Animated shimmers instead of "Loading..." text.
|
||||
4. **Micro-interactions:** Animated heart icons, subtle zoom on card hover.
|
||||
|
||||
### C. Clarity & UX Enhancements
|
||||
|
||||
*Current State:* Functional but dense.
|
||||
*Upgrade Potential:*
|
||||
|
||||
1. **"Sparklines" in Lists:** Small trend lines in the Leaderboard to show if a song is "trending up".
|
||||
2. **Contextual Empty States:** Better illustrations when no data exists (e.g., an empty tour bus SVG for "No Shows").
|
||||
|
||||
## 3. Recommended Implementation Plan (Sprints)
|
||||
|
||||
### Sprint 1: The Visualizer (Charts)
|
||||
|
||||
* **Action:** Install `recharts` (lightweight, native React).
|
||||
* **Deliverable:**
|
||||
* Implement **Song Rating History Scatter Plot**.
|
||||
* Implement **User Attendance Heatmap**.
|
||||
|
||||
### Sprint 2: The Aliveness (Animations)
|
||||
|
||||
* **Action:** Install `framer-motion` (industry standard for React animation).
|
||||
* **Deliverable:**
|
||||
* Add **Page Transitions** to Tab switching.
|
||||
* Add **Micro-interactions** to Rating buttons.
|
||||
* Add **Animated Skeletons** for loading states.
|
||||
|
||||
### Sprint 3: The Deep Dive (Advanced Stats)
|
||||
|
||||
* **Action:** Backend aggregation for complex stats.
|
||||
* **Deliverable:**
|
||||
* "Gap" charts (frequency of play over tours).
|
||||
* "Rating Distribution" histograms.
|
||||
|
||||
## 4. Immediate Next Step Proposal
|
||||
|
||||
I recommend starting with **Sprint 1** to immediately boost the "Professional/Premium" feel of the data pages.
|
||||
|
||||
**Command to run:**
|
||||
`npm install recharts framer-motion clsx tailwind-merge` (Ensure basics are there)
|
||||
98
PLATFORM_EXPANSION_SPEC.md
Normal file
98
PLATFORM_EXPANSION_SPEC.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# 🚀 Elmeg Platform Expansion Spec
|
||||
|
||||
*Moving from "Utility" to "Community"*
|
||||
|
||||
## 1. Module: The "Social Loop" (Interactions & Notifications)
|
||||
|
||||
*Objective: Increase user retention by creating feedback loops.*
|
||||
|
||||
### A. "Likes" & Reactions
|
||||
|
||||
**Problem:** Users can only write text. No lightweight way to acknowledge others.
|
||||
**Solution:** Implement a polymorphic `Reaction` system.
|
||||
|
||||
* **Backend:**
|
||||
* Create `Reaction` model (`user_id`, `entity_type`, `entity_id`, `emoji`).
|
||||
* API: `POST /react`, `DELETE /react`.
|
||||
* **Frontend:**
|
||||
* Add "Heart" / "Like" button to Reviews and Comments.
|
||||
* Display count next to the button.
|
||||
|
||||
### B. Threaded Conversations
|
||||
|
||||
**Problem:** Comments are linear. Replies get lost.
|
||||
**Solution:** infinite nesting (or shallow nesting) for comments.
|
||||
|
||||
* **Backend:** Add `parent_id` to `Comment`.
|
||||
* **Frontend:** "Reply" button reveals nested input. indent child comments.
|
||||
|
||||
### C. The Notification Center
|
||||
|
||||
**Problem:** Users don't know when someone interacts with them.
|
||||
**Solution:** A centralized inbox for alerts.
|
||||
|
||||
* **Triggers:**
|
||||
* User B replies to User A -> Notify User A.
|
||||
* User B likes User A's review -> Notify User A.
|
||||
* **Backend:** `GET /notifications` (paginated), `POST /notifications/{id}/read`.
|
||||
* **Frontend:**
|
||||
* Bell icon in header with red dot badge.
|
||||
* Dropdown or dedicated page listing alerts.
|
||||
* Clicking clears "unread" state and navigates to content.
|
||||
|
||||
---
|
||||
|
||||
## 2. Module: The "Safe Space" (Moderation)
|
||||
|
||||
*Objective: Protect the community as it scales.*
|
||||
|
||||
### A. Reporting System
|
||||
|
||||
**Problem:** Bad actors can post spam/abuse.
|
||||
**Solution:** Crowd-sourced flagging.
|
||||
|
||||
* **Frontend:** "Flag" icon on every user-generated content (UGC).
|
||||
* Modal: Select reason (Spam, Harassment, Spoiler).
|
||||
* **Backend:** `POST /reports`.
|
||||
|
||||
### B. Admin Dashboard
|
||||
|
||||
**Problem:** Reports go into a black hole (database only).
|
||||
**Solution:** A UI for Moderators.
|
||||
|
||||
* **Route:** `/admin/reports` (Protected: Role >= Moderator).
|
||||
* **Features:**
|
||||
* List pending reports.
|
||||
* "Resolve" (Delete Content + Warn User).
|
||||
* "Dismiss" (False Alarm).
|
||||
|
||||
---
|
||||
|
||||
## 3. Module: The "Velvet Rope" (User Journey & Identity)
|
||||
|
||||
*Objective: Make the user feel special and secure.*
|
||||
|
||||
### A. Onboarding Flow
|
||||
|
||||
**Problem:** Registration dumps user on the homepage with no direction.
|
||||
**Solution:** A "Welcome Wizard".
|
||||
|
||||
1. **Welcome:** "Thanks for joining the flock!"
|
||||
2. **Setup:** Upload Avatar (or generate one).
|
||||
3. **Taste:** "Pick your favorite 3 shows" (seeds recommendation engine later).
|
||||
|
||||
### B. Identity Verification
|
||||
|
||||
**Problem:** Anyone can sign up with fake emails.
|
||||
**Solution:** Email Verification (optional for MVP, maybe "Verified User" badge).
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposed Execution Order
|
||||
|
||||
1. **Phase 1: Notifications & Reactions** (High visibility, high engagement).
|
||||
2. **Phase 2: Moderation** (Essential before public launch).
|
||||
3. **Phase 3: Deep User Journey** (Polish).
|
||||
|
||||
**Action Item:**
|
||||
Does this spec cover the requested scope? Shall we begin with **Phase 1 (Notifications)**?
|
||||
96
docker-compose.hetzner.yml
Normal file
96
docker-compose.hetzner.yml
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "127.0.0.1:8020:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- backend_data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://elmeg:elmeg_password@db:5432/elmeg
|
||||
- SECRET_KEY=${SECRET_KEY:-demo-secret-change-in-production}
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8000 --root-path /api --proxy-headers --forwarded-allow-ips '*'
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/docs').raise_for_status()" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- elmeg
|
||||
- ersen_traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.elmeg-backend.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.elmeg-backend.entrypoints=websecure"
|
||||
- "traefik.http.routers.elmeg-backend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.elmeg-backend.priority=100"
|
||||
- "traefik.http.middlewares.elmeg-strip.stripprefix.prefixes=/api"
|
||||
- "traefik.http.routers.elmeg-backend.middlewares=elmeg-strip"
|
||||
- "traefik.http.routers.elmeg-backend.service=elmeg-backend-svc"
|
||||
- "traefik.http.services.elmeg-backend-svc.loadbalancer.server.port=8000"
|
||||
- "traefik.docker.network=ersen_traefik-public"
|
||||
# Direct routes for docs (no strip)
|
||||
- "traefik.http.routers.elmeg-backend-docs.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/docs`, `/openapi.json`)"
|
||||
- "traefik.http.routers.elmeg-backend-docs.entrypoints=websecure"
|
||||
- "traefik.http.routers.elmeg-backend-docs.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.elmeg-backend-docs.priority=100"
|
||||
- "traefik.http.routers.elmeg-backend-docs.service=elmeg-backend-svc"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "127.0.0.1:3020:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=/api
|
||||
- INTERNAL_API_URL=http://backend:8000
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- elmeg
|
||||
- ersen_traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.elmeg-frontend.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && !PathPrefix(`/api`, `/docs`, `/openapi.json`)"
|
||||
- "traefik.http.routers.elmeg-frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.elmeg-frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.elmeg-frontend.priority=50"
|
||||
- "traefik.http.services.elmeg-frontend.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=ersen_traefik-public"
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=elmeg
|
||||
- POSTGRES_PASSWORD=elmeg_password
|
||||
- POSTGRES_DB=elmeg
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U elmeg -d elmeg" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- elmeg
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
backend_data:
|
||||
|
||||
|
||||
networks:
|
||||
elmeg:
|
||||
ersen_traefik-public:
|
||||
external: true
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { Bell } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { useEffect, useState } from "react"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Notification {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
link?: string
|
||||
is_read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const fetchNotifications = () => {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) return
|
||||
|
||||
fetch(`${getApiUrl()}/notifications/`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setNotifications(data)
|
||||
setUnreadCount(data.filter((n: Notification) => !n.is_read).length)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications()
|
||||
// Poll every 60 seconds
|
||||
const interval = setInterval(fetchNotifications, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const markAsRead = (id: number) => {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) return
|
||||
|
||||
fetch(`${getApiUrl()}/notifications/${id}/read`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(() => {
|
||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n))
|
||||
setUnreadCount(prev => Math.max(0, prev - 1))
|
||||
})
|
||||
}
|
||||
|
||||
const markAllRead = () => {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) return
|
||||
|
||||
fetch(`${getApiUrl()}/notifications/mark-all-read`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(() => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })))
|
||||
setUnreadCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-red-600" />
|
||||
)}
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h4 className="font-semibold">Notifications</h4>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={markAllRead} className="text-xs h-auto p-0 text-muted-foreground hover:text-foreground">
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
No notifications
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"p-4 border-b last:border-0 hover:bg-muted/50 transition-colors cursor-pointer",
|
||||
!notification.is_read && "bg-muted/20"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!notification.is_read) markAsRead(notification.id)
|
||||
}}
|
||||
>
|
||||
<Link href={notification.link || "#"} className="block">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(notification.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<span className="h-2 w-2 rounded-full bg-blue-600 flex-shrink-0 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
121
frontend/components/moderation/report-dialog.tsx
Normal file
121
frontend/components/moderation/report-dialog.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Flag } from "lucide-react"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
interface ReportDialogProps {
|
||||
entityType: "comment" | "review" | "nickname"
|
||||
entityId: number
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ReportDialog({ entityType, entityId, trigger }: ReportDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [reason, setReason] = useState("spam")
|
||||
const [details, setDetails] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/moderation/reports`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
reason: reason,
|
||||
details: details // Schema might not have details yet, check backend
|
||||
})
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setOpen(false)
|
||||
alert("Report submitted. Thank you for helping keep the community safe.")
|
||||
} else {
|
||||
alert("Failed to submit report.")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500">
|
||||
<Flag className="h-3 w-3" />
|
||||
<span className="sr-only">Report</span>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Report Content</DialogTitle>
|
||||
<DialogDescription>
|
||||
Help us understand what's wrong with this content.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<RadioGroup value={reason} onValueChange={setReason} className="gap-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="spam" id="spam" />
|
||||
<Label htmlFor="spam">Spam or unwanted commercial content</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="harassment" id="harassment" />
|
||||
<Label htmlFor="harassment">Harassment or hate speech</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="spoiler" id="spoiler" />
|
||||
<Label htmlFor="spoiler">Spoiler or incorrect info</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="other" id="other" />
|
||||
<Label htmlFor="other">Other</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{reason === "other" && (
|
||||
<Textarea
|
||||
placeholder="Please provide more details..."
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? "Submitting..." : "Submit Report"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,15 +3,19 @@
|
|||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { MessageCircle, CornerDownRight, Heart, Flag } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReportDialog } from "@/components/moderation/report-dialog"
|
||||
|
||||
interface Comment {
|
||||
id: number
|
||||
user_id: number
|
||||
content: string
|
||||
created_at: string
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
interface CommentSectionProps {
|
||||
|
|
@ -30,7 +34,7 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
|
|||
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/social/comments?${entityType}_id=${entityId}`)
|
||||
const res = await fetch(`${getApiUrl()}/social/comments?${entityType}_id=${entityId}&limit=100`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setComments(data)
|
||||
|
|
@ -40,9 +44,9 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent, parentId: number | null = null, content: string = newComment) => {
|
||||
e.preventDefault()
|
||||
if (!newComment.trim()) return
|
||||
if (!content.trim()) return
|
||||
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) {
|
||||
|
|
@ -52,7 +56,10 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
|
|||
|
||||
setLoading(true)
|
||||
try {
|
||||
const body: any = { content: newComment }
|
||||
const body: any = {
|
||||
content: content,
|
||||
parent_id: parentId
|
||||
}
|
||||
body[`${entityType}_id`] = entityId
|
||||
|
||||
const res = await fetch(`${getApiUrl()}/social/comments`, {
|
||||
|
|
@ -77,41 +84,156 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// Tree builder
|
||||
const rootComments = comments.filter(c => c.parent_id === null)
|
||||
const getReplies = (parentId: number) => comments.filter(c => c.parent_id === parentId).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-semibold">Comments</h3>
|
||||
<div className="space-y-8">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
Discussion
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Textarea
|
||||
placeholder="Join the discussion..."
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={loading || !newComment.trim()}>
|
||||
{loading ? "Posting..." : "Post Comment"}
|
||||
</Button>
|
||||
</form>
|
||||
{/* Root Post Form */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<form onSubmit={(e) => handleSubmit(e)} className="space-y-4">
|
||||
<Textarea
|
||||
placeholder="Share your thoughts..."
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
required
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={loading || !newComment.trim()}>
|
||||
{loading ? "Posting..." : "Post Comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-muted-foreground">No comments yet.</p>
|
||||
<div className="space-y-6">
|
||||
{rootComments.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">No comments yet. Be the first!</p>
|
||||
) : (
|
||||
comments.map(comment => (
|
||||
<Card key={comment.id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="font-semibold text-sm">User #{comment.user_id}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(comment.created_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
rootComments.map(comment => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
getReplies={getReplies}
|
||||
onReply={handleSubmit}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommentItem({ comment, getReplies, onReply }: {
|
||||
comment: Comment,
|
||||
getReplies: (id: number) => Comment[],
|
||||
onReply: (e: React.FormEvent, parentId: number, content: string) => Promise<void>
|
||||
}) {
|
||||
const [isReplying, setIsReplying] = useState(false)
|
||||
const [replyContent, setReplyContent] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const replies = getReplies(comment.id)
|
||||
|
||||
const handleReplySubmit = async (e: React.FormEvent) => {
|
||||
setSubmitting(true)
|
||||
await onReply(e, comment.id, replyContent)
|
||||
setSubmitting(false)
|
||||
setIsReplying(false)
|
||||
setReplyContent("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
|
||||
{comment.user_id}
|
||||
</div>
|
||||
{(replies.length > 0) && <div className="w-px h-full bg-border my-2" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2 pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">User #{comment.user_id}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
• {formatDistanceToNow(new Date(comment.created_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-6 px-2 text-xs text-muted-foreground gap-1 hover:text-primary", isReplying && "text-primary bg-primary/5")}
|
||||
onClick={() => setIsReplying(!isReplying)}
|
||||
>
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground gap-1 hover:text-red-500">
|
||||
<Heart className="h-3 w-3" />
|
||||
Like
|
||||
</Button>
|
||||
<ReportDialog
|
||||
entityType="comment"
|
||||
entityId={comment.id}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground gap-1 hover:text-red-500">
|
||||
<Flag className="h-3 w-3" />
|
||||
Report
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isReplying && (
|
||||
<div className="mt-4 pl-4 border-l-2 border-primary/20">
|
||||
<form onSubmit={handleReplySubmit} className="space-y-3">
|
||||
<Textarea
|
||||
placeholder={`Reply to User #${comment.user_id}...`}
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
autoFocus
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => setIsReplying(false)}>Cancel</Button>
|
||||
<Button type="submit" size="sm" disabled={submitting || !replyContent.trim()}>
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recursive Replies */}
|
||||
{replies.length > 0 && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{replies.map(reply => (
|
||||
<CommentItem
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
getReplies={getReplies}
|
||||
onReply={onReply}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue