Compare commits
No commits in common. "e683398364d15b2b8f409670b468be4d127afae5" and "5d27045819fd3a161095a9dbb54042de05b02068" have entirely different histories.
e683398364
...
5d27045819
5 changed files with 167 additions and 362 deletions
|
|
@ -1,66 +0,0 @@
|
||||||
# 📊 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)
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
# 🚀 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)**?
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -2,11 +2,10 @@
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Check, X, ShieldAlert, AlertTriangle } from "lucide-react"
|
import { Check, X, ShieldAlert } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
|
|
||||||
interface PendingNickname {
|
interface PendingNickname {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -16,20 +15,8 @@ interface PendingNickname {
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingReport {
|
|
||||||
id: number
|
|
||||||
entity_type: string
|
|
||||||
entity_id: number
|
|
||||||
reason: string
|
|
||||||
details?: string
|
|
||||||
user_id: number
|
|
||||||
created_at: string
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ModDashboardPage() {
|
export default function ModDashboardPage() {
|
||||||
const [pendingNicknames, setPendingNicknames] = useState<PendingNickname[]>([])
|
const [pendingNicknames, setPendingNicknames] = useState<PendingNickname[]>([])
|
||||||
const [pendingReports, setPendingReports] = useState<PendingReport[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
|
@ -46,21 +33,20 @@ export default function ModDashboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [nicknamesRes, reportsRes] = await Promise.all([
|
const res = await fetch(`${getApiUrl()}/moderation/queue/nicknames`, {
|
||||||
fetch(`${getApiUrl()}/moderation/queue/nicknames`, { headers: { Authorization: `Bearer ${token}` } }),
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
fetch(`${getApiUrl()}/moderation/queue/reports`, { headers: { Authorization: `Bearer ${token}` } })
|
})
|
||||||
])
|
|
||||||
|
|
||||||
if (nicknamesRes.status === 403 || reportsRes.status === 403) {
|
if (res.status === 403) {
|
||||||
setError("Access denied: Moderators only")
|
setError("Access denied: Moderators only")
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nicknamesRes.ok || !reportsRes.ok) throw new Error("Failed to fetch queue")
|
if (!res.ok) throw new Error("Failed to fetch queue")
|
||||||
|
|
||||||
setPendingNicknames(await nicknamesRes.json())
|
const data = await res.json()
|
||||||
setPendingReports(await reportsRes.json())
|
setPendingNicknames(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setError("Failed to load moderation queue")
|
setError("Failed to load moderation queue")
|
||||||
|
|
@ -69,14 +55,13 @@ export default function ModDashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNicknameAction = async (id: number, action: "approve" | "reject") => {
|
const handleAction = async (id: number, action: "approve" | "reject") => {
|
||||||
const token = localStorage.getItem("token")
|
const token = localStorage.getItem("token")
|
||||||
if (!token) return
|
if (!token) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Note: Updated backend uses PUT for nicknames
|
|
||||||
const res = await fetch(`${getApiUrl()}/moderation/nicknames/${id}/${action}`, {
|
const res = await fetch(`${getApiUrl()}/moderation/nicknames/${id}/${action}`, {
|
||||||
method: "PUT",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -90,93 +75,22 @@ export default function ModDashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReportAction = async (id: number, action: "resolve" | "dismiss") => {
|
|
||||||
const token = localStorage.getItem("token")
|
|
||||||
if (!token) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Note: Updated backend uses PUT for reports
|
|
||||||
const res = await fetch(`${getApiUrl()}/moderation/reports/${id}/${action}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`Failed to ${action}`)
|
|
||||||
|
|
||||||
setPendingReports(prev => prev.filter(r => r.id !== id))
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
alert(`Error: ${err}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <div className="p-8 text-center">Loading dashboard...</div>
|
if (loading) return <div className="p-8 text-center">Loading dashboard...</div>
|
||||||
if (error) return <div className="p-8 text-center text-red-500 font-bold">{error}</div>
|
if (error) return <div className="p-8 text-center text-red-500 font-bold">{error}</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-8 flex flex-col gap-6 max-w-5xl">
|
<div className="flex flex-col gap-6">
|
||||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
<ShieldAlert className="h-8 w-8 text-primary" />
|
<ShieldAlert className="h-8 w-8 text-primary" />
|
||||||
Moderator Dashboard
|
Moderator Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Tabs defaultValue="reports">
|
<Tabs defaultValue="nicknames">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="reports">Reports ({pendingReports.length})</TabsTrigger>
|
|
||||||
<TabsTrigger value="nicknames">Nicknames ({pendingNicknames.length})</TabsTrigger>
|
<TabsTrigger value="nicknames">Nicknames ({pendingNicknames.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="reports">Reports (0)</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="reports">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User Reports</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{pendingReports.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground py-8 text-center">No pending reports. Community is safe! 🛡️</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{pendingReports.map(report => (
|
|
||||||
<div key={report.id} className="flex flex-col md:flex-row gap-4 justify-between border p-4 rounded-lg bg-red-50/10 border-red-100 dark:border-red-900/20">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Badge variant="destructive" className="uppercase text-[10px]">
|
|
||||||
{report.reason}
|
|
||||||
</Badge>
|
|
||||||
<span className="font-semibold">{report.entity_type} #{report.entity_id}</span>
|
|
||||||
</div>
|
|
||||||
{report.details && (
|
|
||||||
<p className="text-sm italic text-muted-foreground mb-2">"{report.details}"</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Reported by User #{report.user_id} • {new Date(report.created_at).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
className="bg-red-600 hover:bg-red-700"
|
|
||||||
onClick={() => handleReportAction(report.id, "resolve")}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4 mr-1" /> Resolve (Ban/Delete)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleReportAction(report.id, "dismiss")}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-1" /> Dismiss
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="nicknames">
|
<TabsContent value="nicknames">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -184,7 +98,7 @@ export default function ModDashboardPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{pendingNicknames.length === 0 ? (
|
{pendingNicknames.length === 0 ? (
|
||||||
<p className="text-muted-foreground py-8 text-center">No pending nicknames.</p>
|
<p className="text-muted-foreground py-8 text-center">No pending nicknames. Good job!</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pendingNicknames.map((item) => (
|
{pendingNicknames.map((item) => (
|
||||||
|
|
@ -203,7 +117,7 @@ export default function ModDashboardPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||||
onClick={() => handleNicknameAction(item.id, "approve")}
|
onClick={() => handleAction(item.id, "approve")}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-1" /> Approve
|
<Check className="h-4 w-4 mr-1" /> Approve
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -211,7 +125,7 @@ export default function ModDashboardPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
onClick={() => handleNicknameAction(item.id, "reject")}
|
onClick={() => handleAction(item.id, "reject")}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" /> Reject
|
<X className="h-4 w-4 mr-1" /> Reject
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -223,6 +137,17 @@ export default function ModDashboardPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="reports">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Reports</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">Report queue coming soon...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
140
frontend/components/layout/notification-bell.tsx
Normal file
140
frontend/components/layout/notification-bell.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue