draupnir4all-web

git clone git://archive.git.mtrnord.blog/MTRNord/draupnir4all-web.git
Log | Files | Refs | README | LICENSE

commit af2d92012b646c6f1bb1ab1d3f4fd04b7451af5d
parent 4ccff93c728265300094ce27cdf62df9c1bbc6df
Author: MTRNord <mtrnord1@gmail.com>
Date:   Tue, 22 Apr 2025 15:32:02 +0200

Add statistics page and set up some more auth related things like session management

Signed-off-by: MTRNord <mtrnord1@gmail.com>

Diffstat:
Mpackage.json | 1+
Mpnpm-lock.yaml | 79++++++++-----------------------------------------------------------------------
Asrc/app/api/auth/login/route.ts | 35+++++++++++++++++++++++++++++++++++
Asrc/app/api/auth/logout/route.ts | 17+++++++++++++++++
Asrc/app/api/auth/register/route.ts | 35+++++++++++++++++++++++++++++++++++
Asrc/app/api/auth/session/route.ts | 17+++++++++++++++++
Asrc/app/dashboard/analytics-dashboard.tsx | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/dashboard/components/bans-pane.tsx | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/dashboard/components/dashboard-header.tsx | 479+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/components/dashboard/modals/add-protected-room.tsx -> src/app/dashboard/components/modals/add-protected-room.tsx | 0
Asrc/app/dashboard/components/overview-tab.tsx | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/dashboard/components/reports-tab.tsx | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/dashboard/layout.tsx | 24++++++++++++++++++++++++
Msrc/app/dashboard/page.tsx | 592++++++++++---------------------------------------------------------------------
Msrc/app/dashboard/team-management.tsx | 2+-
Msrc/app/layout.tsx | 7++++---
Msrc/app/login/page.tsx | 32+++++++++-----------------------
Msrc/app/register/page.tsx | 32+++++++++-----------------------
Asrc/components/analytics/bar-chart.tsx | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/analytics/heatmap.tsx | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/analytics/horizontal-bar-chart.tsx | 46++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/analytics/pie-chart.tsx | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/components/dashboard/bans-pane.tsx | 210-------------------------------------------------------------------------------
Dsrc/components/dashboard/overview-pane.tsx | 152-------------------------------------------------------------------------------
Dsrc/components/dashboard/reports-pane.tsx | 252-------------------------------------------------------------------------------
Dsrc/components/ui/calendar.tsx | 66------------------------------------------------------------------
Dsrc/components/ui/chart.tsx | 365-------------------------------------------------------------------------------
Msrc/components/ui/use-toast.ts | 33+++++++++++++++++----------------
Asrc/contexts/session-context.tsx | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/hooks/use-mobile.tsx | 19-------------------
Dsrc/hooks/use-toast.ts | 194-------------------------------------------------------------------------------
Asrc/lib/auth.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/middleware.ts | 34++++++++++++++++++++++++++++++++++
Mtailwind.config.ts | 4++++
34 files changed, 2418 insertions(+), 1913 deletions(-)

diff --git a/package.json b/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-tabs": "^1.1.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jose": "^6.0.10", "lucide-react": "^0.503.0", "next": "15.3.1-canary.14", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + jose: + specifier: ^6.0.10 + version: 6.0.10 lucide-react: specifier: ^0.503.0 version: 0.503.0(react@19.1.0) @@ -84,9 +87,6 @@ importers: '@types/react-dom': specifier: ^19 version: 19.1.2(@types/react@19.1.2) - autoprefixer: - specifier: ^10.4.21 - version: 10.4.21(postcss@8.5.3) eslint: specifier: ^9 version: 9.25.0(jiti@2.4.2) @@ -1082,13 +1082,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - autoprefixer@10.4.21: - resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1118,11 +1111,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.24.4: - resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1278,9 +1266,6 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.139: - resolution: {integrity: sha512-GGnRYOTdN5LYpwbIr0rwP/ZHOQSvAF6TG0LSzp28uCBb9JiXHJGmaaKw29qjNJc5bGnnp6kXJqRnGMQoELwi5w==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1319,10 +1304,6 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1498,9 +1479,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1747,6 +1725,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jose@6.0.10: + resolution: {integrity: sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1887,17 +1868,10 @@ packages: sass: optional: true - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2379,12 +2353,6 @@ packages: unrs-resolver@1.6.1: resolution: {integrity: sha512-PLDI7BRVaI1C0x8mXr8leLPIOPPF1wCRFyKIswJAPJG3LdMxWNiAVvlTvmff5DSezapWFLagk18NF2cCNhe8Fg==} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3429,16 +3397,6 @@ snapshots: async-function@1.0.0: {} - autoprefixer@10.4.21(postcss@8.5.3): - dependencies: - browserslist: 4.24.4 - caniuse-lite: 1.0.30001715 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.1.1 - postcss: 8.5.3 - postcss-value-parser: 4.2.0 - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -3464,13 +3422,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.24.4: - dependencies: - caniuse-lite: 1.0.30001715 - electron-to-chromium: 1.5.139 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.24.4) - busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -3624,8 +3575,6 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.139: {} - emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -3728,8 +3677,6 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - escalade@3.2.0: {} - escape-string-regexp@4.0.0: {} eslint-config-next@15.3.1-canary.14(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3): @@ -3988,8 +3935,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fraction.js@4.3.7: {} - fsevents@2.3.3: optional: true @@ -4251,6 +4196,8 @@ snapshots: jiti@2.4.2: optional: true + jose@6.0.10: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -4376,12 +4323,8 @@ snapshots: - '@babel/core' - babel-plugin-macros - node-releases@2.0.19: {} - normalize-path@3.0.0: {} - normalize-range@0.1.2: {} - object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -4980,12 +4923,6 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.6.1 '@unrs/resolver-binding-win32-x64-msvc': 1.6.1 - update-browserslist-db@1.1.3(browserslist@4.24.4): - dependencies: - browserslist: 4.24.4 - escalade: 3.2.0 - picocolors: 1.1.1 - uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts @@ -0,0 +1,35 @@ +import { type NextRequest, NextResponse } from "next/server" +import { createSession, setSessionCookie } from "@/lib/auth" + +export async function POST(request: NextRequest) { + try { + const { matrixId } = await request.json() + + if (!matrixId) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }) + } + + // In a real app, you would verify the user's credentials with Matrix + // For demo purposes, we'll create a mock user + const user = { + id: "user_" + Math.random().toString(36).substring(2, 9), + matrixId, + displayName: matrixId.split(":")[0].substring(1), + isAdmin: matrixId.includes("admin"), + } + + // Create a session token + const token = await createSession(user) + + // Create a response + const response = NextResponse.json({ success: true, user }) + + // Set the session cookie + await setSessionCookie(token, response) + + return response + } catch (error) { + console.error("Login failed:", error) + return NextResponse.json({ error: "Authentication failed" }, { status: 500 }) + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server" +import { clearSession } from "@/lib/auth" + +export async function POST() { + try { + // Create a response + const response = NextResponse.json({ success: true }) + + // Clear the session cookie + await clearSession(response) + + return response + } catch (error) { + console.error("Logout failed:", error) + return NextResponse.json({ error: "Logout failed" }, { status: 500 }) + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts @@ -0,0 +1,35 @@ +import { type NextRequest, NextResponse } from "next/server" +import { createSession, setSessionCookie } from "@/lib/auth" + +export async function POST(request: NextRequest) { + try { + const { matrixId } = await request.json() + + if (!matrixId) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }) + } + + // In a real app, you would register the user with Matrix + // For demo purposes, we'll create a mock user + const user = { + id: "user_" + Math.random().toString(36).substring(2, 9), + matrixId, + displayName: matrixId.split(":")[0].substring(1), + isAdmin: false, + } + + // Create a session token + const token = await createSession(user) + + // Create a response + const response = NextResponse.json({ success: true, user }) + + // Set the session cookie + await setSessionCookie(token, response) + + return response + } catch (error) { + console.error("Registration failed:", error) + return NextResponse.json({ error: "Registration failed" }, { status: 500 }) + } +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts @@ -0,0 +1,17 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" + +export async function GET(request: NextRequest) { + try { + const user = await getSessionUser(request) + + if (!user) { + return NextResponse.json({ authenticated: false }, { status: 401 }) + } + + return NextResponse.json({ authenticated: true, user }) + } catch (error) { + console.error("Session check failed:", error) + return NextResponse.json({ authenticated: false }, { status: 500 }) + } +} diff --git a/src/app/dashboard/analytics-dashboard.tsx b/src/app/dashboard/analytics-dashboard.tsx @@ -0,0 +1,394 @@ +"use client" + +import { useState } from "react" +import { Filter, TrendingUp, Users } from "lucide-react" +import { useRouter } from "next/navigation" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Button } from "@/components/ui/button" + +import { PieChart } from "@/components/analytics/pie-chart" +import { Heatmap } from "@/components/analytics/heatmap" +import { BarChart } from "@/components/analytics/bar-chart" +import { HorizontalBarChart } from "@/components/analytics/horizontal-bar-chart" + +// Mock data for heatmaps +const generateHeatmapData = (intensity = 1, seed = 42) => { + const today = new Date() + const data = [] + + // Generate data for the past year (52 weeks) + for (let i = 0; i < 364; i++) { + const date = new Date(today) + date.setDate(today.getDate() - i) + + // Use a simple algorithm to generate semi-random but consistent data + const dayOfYear = Math.floor((date.getTime() - new Date(date.getFullYear(), 0, 0).getTime()) / 86400000) + const value = Math.floor(Math.sin(dayOfYear * 0.1 + seed) * Math.cos(date.getMonth() * 0.3 + seed) * 10 * intensity) + + data.push({ + date: date.toISOString().split("T")[0], + count: Math.max(0, value), + }) + } + + // Sort by date (oldest first) + return data.reverse() +} + +// Generate mock data +const reportsHeatmapData = generateHeatmapData(1.5, 42) +const bansHeatmapData = generateHeatmapData(1, 123) + +// Mock data for charts +const roomActivityData = [ + { name: "#general:matrix.org", reports: 42, bans: 15 }, + { name: "#support:matrix.org", reports: 28, bans: 9 }, + { name: "#community:matrix.org", reports: 17, bans: 5 }, + { name: "#development:matrix.org", reports: 8, bans: 2 }, + { name: "#random:matrix.org", reports: 5, bans: 1 }, +] + +const reportTypesData = [ + { id: 1, label: "Spam", value: 45, color: "#f97316" }, + { id: 2, label: "Harassment", value: 32, color: "#ef4444" }, + { id: 3, label: "Inappropriate Content", value: 28, color: "#ec4899" }, + { id: 4, label: "Scam", value: 15, color: "#f59e0b" }, + { id: 5, label: "Other", value: 10, color: "#6366f1" }, +] + +const bannedServersData = [ + { label: "spam.org", value: 28, secondaryLabel: "28 users" }, + { label: "scam.net", value: 17, secondaryLabel: "17 users" }, + { label: "badactor.io", value: 12, secondaryLabel: "12 users" }, + { label: "malicious.com", value: 9, secondaryLabel: "9 users" }, + { label: "troll.xyz", value: 7, secondaryLabel: "7 users" }, +] + +// Enhanced monthly activity data with more realistic patterns +const monthlyActivityData = [ + { month: "Jan", reports: 24, bans: 8, year: 2025 }, + { month: "Feb", reports: 32, bans: 12, year: 2025 }, + { month: "Mar", reports: 45, bans: 18, year: 2025 }, + { month: "Apr", reports: 38, bans: 15, year: 2025 }, + { month: "May", reports: 29, bans: 11, year: 2025 }, + { month: "Jun", reports: 35, bans: 14, year: 2025 }, + { month: "Jul", reports: 42, bans: 17, year: 2025 }, + { month: "Aug", reports: 50, bans: 22, year: 2025 }, + { month: "Sep", reports: 43, bans: 19, year: 2025 }, + { month: "Oct", reports: 37, bans: 16, year: 2025 }, + { month: "Nov", reports: 31, bans: 13, year: 2025 }, + { month: "Dec", reports: 28, bans: 10, year: 2025 }, +] + +// Transform monthly data for the BarChart component +const monthlyBarChartData = monthlyActivityData.map((item) => ({ + label: `${item.month} ${item.year}`, + values: [ + { value: item.reports, color: "#9333ea", label: "Reports" }, + { value: item.bans, color: "#dc2626", label: "Bans" }, + ], +})) + +interface AnalyticsDashboardProps { + selectedTeam: { + id: string + name: string + } +} + +export function AnalyticsDashboard({ selectedTeam }: AnalyticsDashboardProps) { + const [timeRange, setTimeRange] = useState("year") + const [heatmapType, setHeatmapType] = useState("reports") + const router = useRouter() + + const handleViewAllBannedServers = () => { + // Navigate to the bans tab + router.push("/dashboard?tab=bans") + } + + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h2 className="text-2xl font-bold">{selectedTeam.name} Analytics</h2> + <div className="flex items-center gap-2"> + <Select value={timeRange} onValueChange={setTimeRange}> + <SelectTrigger className="w-[120px] h-8 bg-gray-900 border-gray-800"> + <SelectValue placeholder="Time Range" /> + </SelectTrigger> + <SelectContent className="bg-gray-900 border-gray-800"> + <SelectItem value="month">Past Month</SelectItem> + <SelectItem value="quarter">Past Quarter</SelectItem> + <SelectItem value="year">Past Year</SelectItem> + <SelectItem value="all">All Time</SelectItem> + </SelectContent> + </Select> + <Button variant="outline" size="sm" className="h-8 border-gray-800 text-gray-400 hover:text-white"> + <Filter className="h-3.5 w-3.5 mr-1" /> + Filter + </Button> + </div> + </div> + + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">Total Reports</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">247</div> + <div className="flex items-center text-xs text-green-400"> + <TrendingUp className="h-3.5 w-3.5 mr-1" /> + <span>+12% from previous period</span> + </div> + </CardContent> + </Card> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">Bans Issued</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">98</div> + <div className="flex items-center text-xs text-red-400"> + <TrendingUp className="h-3.5 w-3.5 mr-1" /> + <span>+5% from previous period</span> + </div> + </CardContent> + </Card> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">Unique Reporters</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">42</div> + <div className="flex items-center text-xs text-green-400"> + <TrendingUp className="h-3.5 w-3.5 mr-1" /> + <span>+8% from previous period</span> + </div> + </CardContent> + </Card> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">Avg. Response Time</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">1.4h</div> + <div className="flex items-center text-xs text-green-400"> + <TrendingUp className="h-3.5 w-3.5 mr-1 rotate-180" /> + <span>-15% from previous period</span> + </div> + </CardContent> + </Card> + </div> + + <Card className="border-gray-800 bg-gray-950"> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>Activity Heatmap</CardTitle> + <CardDescription className="text-gray-400"> + Visualization of moderation activity over time + </CardDescription> + </div> + <Tabs value={heatmapType} onValueChange={setHeatmapType} className="w-[240px]"> + <TabsList className="bg-gray-900"> + <TabsTrigger value="reports" className="text-xs"> + Reports + </TabsTrigger> + <TabsTrigger value="bans" className="text-xs"> + Bans + </TabsTrigger> + </TabsList> + </Tabs> + </div> + </CardHeader> + <CardContent className="overflow-hidden"> + <Heatmap + data={heatmapType === "reports" ? reportsHeatmapData : bansHeatmapData} + title={`${heatmapType === "reports" ? "Reports Activity" : "Ban Actions"} (Past Year)`} + colorIntensityLabel={heatmapType === "reports" ? "reports" : "bans"} + /> + </CardContent> + </Card> + + <div className="grid gap-4 md:grid-cols-2"> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader> + <CardTitle>Room Activity</CardTitle> + <CardDescription className="text-gray-400">Reports and bans by room</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {roomActivityData.map((room, index) => ( + <div key={index} className="space-y-2"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">{room.name}</span> + <span className="text-xs text-gray-400">{room.reports + room.bans} actions</span> + </div> + <div className="flex h-2 overflow-hidden rounded-full bg-gray-900"> + <div + className="bg-purple-600" + style={{ width: `${(room.reports / (room.reports + room.bans)) * 100}%` }} + title={`${room.reports} reports`} + ></div> + <div + className="bg-red-600" + style={{ width: `${(room.bans / (room.reports + room.bans)) * 100}%` }} + title={`${room.bans} bans`} + ></div> + </div> + <div className="flex items-center justify-between text-xs text-gray-400"> + <div className="flex items-center gap-1"> + <div className="h-2 w-2 rounded-full bg-purple-600"></div> + <span>{room.reports} reports</span> + </div> + <div className="flex items-center gap-1"> + <div className="h-2 w-2 rounded-full bg-red-600"></div> + <span>{room.bans} bans</span> + </div> + </div> + </div> + ))} + </div> + </CardContent> + </Card> + + <Card className="border-gray-800 bg-gray-950"> + <CardHeader> + <CardTitle>Report Types</CardTitle> + <CardDescription className="text-gray-400">Distribution of report reasons</CardDescription> + </CardHeader> + <CardContent> + <PieChart data={reportTypesData} centerLabel="Report Types" /> + </CardContent> + </Card> + </div> + + <div className="grid gap-4 md:grid-cols-2"> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader> + <CardTitle>Monthly Activity</CardTitle> + <CardDescription className="text-gray-400">Reports and bans over the past year</CardDescription> + </CardHeader> + <CardContent> + <div className="h-[250px] w-full"> + <BarChart data={monthlyBarChartData} height={200} /> + </div> + </CardContent> + </Card> + + <Card className="border-gray-800 bg-gray-950"> + <CardHeader> + <CardTitle>Most Banned Servers</CardTitle> + <CardDescription className="text-gray-400">Servers with the most banned users</CardDescription> + </CardHeader> + <CardContent> + <HorizontalBarChart data={bannedServersData} color="#dc2626" /> + <div className="mt-4 flex items-center justify-center"> + <Button + variant="outline" + size="sm" + className="border-gray-800 text-gray-400 hover:text-white" + onClick={handleViewAllBannedServers} + > + <Users className="mr-2 h-4 w-4" /> + View All Banned Servers + </Button> + </div> + </CardContent> + </Card> + </div> + + <Card className="border-gray-800 bg-gray-950"> + <CardHeader> + <CardTitle>Moderation Efficiency</CardTitle> + <CardDescription className="text-gray-400"> + Key performance indicators for your moderation team + </CardDescription> + </CardHeader> + <CardContent> + <div className="grid gap-4 md:grid-cols-3"> + <div className="space-y-2"> + <h3 className="text-sm font-medium text-gray-400">Average Response Time</h3> + <div className="flex items-end gap-2"> + <span className="text-2xl font-bold">1.4h</span> + <span className="text-xs text-green-400">-15% from last month</span> + </div> + <p className="text-xs text-gray-400">Time between report creation and moderator action</p> + </div> + + <div className="space-y-2"> + <h3 className="text-sm font-medium text-gray-400">Resolution Rate</h3> + <div className="flex items-end gap-2"> + <span className="text-2xl font-bold">92%</span> + <span className="text-xs text-green-400">+3% from last month</span> + </div> + <p className="text-xs text-gray-400">Percentage of reports that were resolved</p> + </div> + + <div className="space-y-2"> + <h3 className="text-sm font-medium text-gray-400">Moderator Activity</h3> + <div className="flex items-end gap-2"> + <span className="text-2xl font-bold">4.2</span> + <span className="text-xs text-gray-400">actions per day</span> + </div> + <p className="text-xs text-gray-400">Average number of moderation actions per day</p> + </div> + </div> + + <div className="mt-6"> + <h3 className="mb-2 text-sm font-medium text-gray-400">Moderator Performance</h3> + <div className="space-y-3"> + <div className="flex items-center gap-3"> + <div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-900 text-xs font-medium"> + AM + </div> + <div className="flex-1"> + <div className="flex items-center justify-between"> + <span className="text-sm">@admin:matrix.org</span> + <span className="text-xs text-gray-400">42 actions</span> + </div> + <div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-900"> + <div className="h-full rounded-full bg-purple-600" style={{ width: "85%" }}></div> + </div> + </div> + </div> + + <div className="flex items-center gap-3"> + <div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-900 text-xs font-medium"> + M1 + </div> + <div className="flex-1"> + <div className="flex items-center justify-between"> + <span className="text-sm">@mod1:matrix.org</span> + <span className="text-xs text-gray-400">36 actions</span> + </div> + <div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-900"> + <div className="h-full rounded-full bg-blue-600" style={{ width: "72%" }}></div> + </div> + </div> + </div> + + <div className="flex items-center gap-3"> + <div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-900 text-xs font-medium"> + M2 + </div> + <div className="flex-1"> + <div className="flex items-center justify-between"> + <span className="text-sm">@mod2:matrix.org</span> + <span className="text-xs text-gray-400">28 actions</span> + </div> + <div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-900"> + <div className="h-full rounded-full bg-green-600" style={{ width: "56%" }}></div> + </div> + </div> + </div> + </div> + </div> + </CardContent> + </Card> + </div> + ) +} diff --git a/src/app/dashboard/components/bans-pane.tsx b/src/app/dashboard/components/bans-pane.tsx @@ -0,0 +1,212 @@ +"use client"; +import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Ban, Badge } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label" +import { useState } from "react"; +import { PolicyList } from "../page"; + +interface BansProps { + policyLists: PolicyList[]; +} + +export default function Bans({ policyLists }: BansProps) { + const [selectedPolicyList, setSelectedPolicyList] = useState("global") + const selectedList = policyLists.find((list) => list.id === selectedPolicyList) || policyLists[0] || { entries: [] } + + return ( + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <div className="flex items-center justify-between"> + <CardTitle>Policy Lists</CardTitle> + <Dialog> + <DialogTrigger asChild> + <Button size="sm" className="bg-purple-600 text-white hover:bg-purple-700"> + <Ban className="mr-2 h-4 w-4" /> + Add Ban + </Button> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + <DialogHeader> + <DialogTitle>Add New Ban</DialogTitle> + <DialogDescription className="text-gray-400"> + Ban a user or server from your Matrix rooms + </DialogDescription> + </DialogHeader> + <div className="space-y-4 py-4"> + <div className="space-y-2"> + <Label htmlFor="ban-type">Ban Type</Label> + <Select defaultValue="user"> + <SelectTrigger className="bg-gray-900 border-gray-800"> + <SelectValue placeholder="Select ban type" /> + </SelectTrigger> + <SelectContent className="bg-gray-900 border-gray-800"> + <SelectItem value="user">User</SelectItem> + <SelectItem value="server">Server</SelectItem> + <SelectItem value="room">Room</SelectItem> + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="ban-target">Target</Label> + <Input + id="ban-target" + placeholder="@user:matrix.org" + className="bg-gray-900 border-gray-800" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="ban-reason">Reason</Label> + <Textarea + id="ban-reason" + placeholder="Reason for the ban" + className="bg-gray-900 border-gray-800" + /> + </div> + <div className="space-y-2"> + <Label>Policy Lists</Label> + <div className="space-y-2 mt-1"> + {policyLists + .filter((list) => !list.readOnly) + .map((list) => ( + <div key={list.id} className="flex items-center space-x-2"> + <Checkbox id={`list-${list.id}`} /> + <Label htmlFor={`list-${list.id}`} className="font-normal"> + {list.name} + </Label> + </div> + ))} + </div> + </div> + <div className="space-y-2"> + <p className="text-xs text-gray-400"> + Bans are applied to all protected rooms monitored by this bot. + </p> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" + > + Cancel + </Button> + <Button className="bg-red-600 text-white hover:bg-red-700">Add Ban</Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + <CardDescription className="text-gray-400">Manage ban lists across your Matrix rooms</CardDescription> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> + <div className="space-y-4"> + <h3 className="text-sm font-medium text-gray-400">Policy Lists</h3> + <div className="space-y-2"> + {policyLists.length > 0 ? ( + policyLists.map((list) => ( + <div + key={list.id} + className={`p-3 rounded-md border cursor-pointer transition-colors ${selectedPolicyList === list.id + ? "bg-gray-800 border-purple-600" + : "bg-gray-900 border-gray-800 hover:border-gray-700" + }`} + onClick={() => setSelectedPolicyList(list.id)} + > + <div className="flex items-center justify-between mb-1"> + <span className="font-medium">{list.name}</span> + {list.readOnly ? ( + <Badge className="bg-gray-800 text-gray-400">Read Only</Badge> + ) : ( + <Badge className="bg-green-900/50 text-green-300">Editable</Badge> + )} + </div> + <p className="text-xs text-gray-400">{list.description}</p> + <p className="text-xs text-gray-500 mt-1">{list.entries.length} entries</p> + </div> + )) + ) : ( + <div className="flex flex-col items-center justify-center p-8 text-center"> + <Ban className="h-8 w-8 text-gray-500 mb-2" /> + <p className="text-gray-400">No policy lists available</p> + </div> + )} + </div> + </div> + + <div className="md:col-span-2 space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-sm font-medium text-gray-400"> + {selectedList.name || "Policy List"} Entries + </h3> + <div className="flex items-center gap-2"> + <Input placeholder="Search entries..." className="h-8 w-[200px] bg-gray-900 border-gray-800" /> + {selectedList && !selectedList.readOnly && ( + <Dialog> + <DialogTrigger asChild> + <Button size="sm" className="bg-purple-600 text-white hover:bg-purple-700"> + Add Entry + </Button> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + {/* Add entry dialog content */} + </DialogContent> + </Dialog> + )} + </div> + </div> + + <ScrollArea className="h-[400px] rounded-md border border-gray-800"> + <div className="p-4 space-y-3"> + {selectedList && selectedList.entries && selectedList.entries.length > 0 ? ( + selectedList.entries.map((entry) => ( + <div key={entry.id} className="p-3 rounded-md bg-gray-900 border border-gray-800"> + <div className="flex items-start justify-between"> + <div> + <p className="font-medium">{entry.target}</p> + <p className="text-sm text-gray-400 mt-1">{entry.reason}</p> + <p className="text-xs text-gray-500 mt-1"> + {new Date(entry.timestamp).toLocaleString()} + </p> + </div> + {!selectedList.readOnly && ( + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0 text-gray-400 hover:text-red-400" + > + <Ban className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </Button> + )} + </div> + </div> + )) + ) : ( + <div className="flex flex-col items-center justify-center h-full p-6 text-center"> + <Ban className="h-8 w-8 text-gray-500 mb-2" /> + <p className="text-gray-400">No ban entries found</p> + </div> + )} + </div> + </ScrollArea> + </div> + </div> + </CardContent> + </Card> + ); +} +\ No newline at end of file diff --git a/src/app/dashboard/components/dashboard-header.tsx b/src/app/dashboard/components/dashboard-header.tsx @@ -0,0 +1,479 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { Shield, Bell, Menu, Ban, UserX, LogOut, Crown, ChevronDown, Plus, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { ScrollArea } from "@/components/ui/scroll-area" +import { useSession } from "@/contexts/session-context" + +interface Notification { + id: string + type: string + title: string + message: string + timestamp: string + read: boolean + teamId?: string +} + +interface Team { + id: string + name: string + description: string +} + +interface DashboardHeaderProps { + selectedTeam: Team + onTeamChange: (teamId: string) => void + activeTab: string + setActiveTab: (tab: string) => void + teams: Team[] +} + +export function DashboardHeader({ selectedTeam, onTeamChange, activeTab, setActiveTab, teams }: DashboardHeaderProps) { + const { logout } = useSession() + const [notifications, setNotifications] = useState<Notification[]>([ + { + id: "notif1", + type: "report", + title: "New report", + message: "A new message has been reported in #general", + timestamp: "2025-04-21T15:30:00Z", + read: false, + teamId: "team1", + }, + { + id: "notif2", + type: "ban", + title: "User banned", + message: "@spammer:matrix.org has been banned by @mod1:matrix.org", + timestamp: "2025-04-21T14:45:00Z", + read: false, + teamId: "team1", + }, + { + id: "notif3", + type: "invite", + title: "Team invitation", + message: "You've been invited to join the Support Team Bot", + timestamp: "2025-04-21T12:15:00Z", + read: true, + teamId: "team2", + }, + ]) + + const teamNotifications = notifications.filter((n) => n.teamId === selectedTeam.id || !n.teamId) + const unreadCount = teamNotifications.filter((n) => !n.read).length + + const markNotificationAsRead = (id: string) => { + setNotifications(notifications.map((n) => (n.id === id ? { ...n, read: true } : n))) + } + + const clearAllNotifications = () => { + setNotifications(notifications.map((n) => ({ ...n, read: true }))) + } + + const deleteNotification = (id: string) => { + setNotifications(notifications.filter((n) => n.id !== id)) + } + + const handleLogout = async () => { + await logout() + } + + return ( + <header className="sticky top-0 z-10 border-b border-gray-800 bg-black/80 backdrop-blur-sm"> + <div className="container flex h-16 items-center justify-between px-4 md:px-6"> + <div className="flex items-center gap-4"> + <Link href="/" className="flex items-center gap-2"> + <Shield className="h-6 w-6 text-purple-400" /> + <span className="text-xl font-bold tracking-tighter">Draupnir4All</span> + </Link> + + {/* Global Team Selector */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" className="border-purple-500 text-purple-400 hover:bg-purple-950"> + <Crown className="mr-2 h-4 w-4" /> + {selectedTeam.name} + <ChevronDown className="ml-2 h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-56 bg-gray-900 border-gray-800"> + <DropdownMenuLabel>Your Bots</DropdownMenuLabel> + <DropdownMenuSeparator className="bg-gray-800" /> + {teams.map((team) => ( + <DropdownMenuItem + key={team.id} + className={selectedTeam.id === team.id ? "bg-gray-800" : ""} + onClick={() => onTeamChange(team.id)} + > + {team.name} + </DropdownMenuItem> + ))} + <DropdownMenuSeparator className="bg-gray-800" /> + <Dialog> + <DialogTrigger asChild> + <DropdownMenuItem> + <Plus className="mr-2 h-4 w-4" /> + Create New Bot + </DropdownMenuItem> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + <DialogHeader> + <DialogTitle>Create New Bot</DialogTitle> + <DialogDescription className="text-gray-400"> + Set up a new Draupnir bot for a different community + </DialogDescription> + </DialogHeader> + <div className="space-y-4 py-4"> + <div className="space-y-2"> + <Label htmlFor="bot-name">Bot Name</Label> + <Input id="bot-name" placeholder="My Community Bot" className="bg-gray-900 border-gray-800" /> + </div> + <div className="space-y-2"> + <Label htmlFor="bot-description">Description</Label> + <Input + id="bot-description" + placeholder="Moderation for my community" + className="bg-gray-900 border-gray-800" + /> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" + > + Cancel + </Button> + <Button className="bg-purple-600 text-white hover:bg-purple-700">Create Bot</Button> + </DialogFooter> + </DialogContent> + </Dialog> + </DropdownMenuContent> + </DropdownMenu> + </div> + + <div className="flex items-center gap-4"> + {/* Notifications */} + <Popover> + <PopoverTrigger asChild> + <Button variant="ghost" size="icon" className="relative text-gray-400 hover:text-white"> + <Bell className="h-5 w-5" /> + {unreadCount > 0 && ( + <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-medium text-white"> + {unreadCount} + </span> + )} + </Button> + </PopoverTrigger> + <PopoverContent className="w-80 p-0 bg-gray-900 border-gray-800"> + <div className="flex items-center justify-between border-b border-gray-800 p-3"> + <h3 className="font-medium">Notifications</h3> + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="sm" + className="h-7 px-2 text-xs text-gray-400 hover:text-white" + onClick={clearAllNotifications} + > + Clear all + </Button> + </div> + </div> + <ScrollArea className="h-[300px]"> + {teamNotifications.length > 0 ? ( + <div className="space-y-1 p-1"> + {teamNotifications.map((notification) => ( + <div + key={notification.id} + className={`flex items-start p-3 rounded-md ${notification.read ? "bg-gray-900" : "bg-gray-800"}`} + onClick={() => markNotificationAsRead(notification.id)} + > + <div + className={`rounded-full p-1.5 mr-3 ${notification.type === "report" + ? "bg-yellow-500/20" + : notification.type === "ban" + ? "bg-red-500/20" + : "bg-blue-500/20" + }`} + > + {notification.type === "report" ? ( + <Bell className="h-3.5 w-3.5 text-yellow-500" /> + ) : notification.type === "ban" ? ( + <Ban className="h-3.5 w-3.5 text-red-500" /> + ) : ( + <Plus className="h-3.5 w-3.5 text-blue-500" /> + )} + </div> + <div className="flex-1 min-w-0"> + <div className="flex items-center justify-between"> + <p className="text-sm font-medium">{notification.title}</p> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0 text-gray-500 hover:text-gray-300" + onClick={(e) => { + e.stopPropagation() + deleteNotification(notification.id) + }} + > + <X className="h-3 w-3" /> + <span className="sr-only">Dismiss</span> + </Button> + </div> + <p className="text-xs text-gray-400 line-clamp-2">{notification.message}</p> + <p className="text-xs text-gray-500 mt-1"> + {new Date(notification.timestamp).toLocaleString()} + </p> + </div> + </div> + ))} + </div> + ) : ( + <div className="flex flex-col items-center justify-center h-full p-6 text-center"> + <Bell className="h-8 w-8 text-gray-600 mb-2" /> + <p className="text-gray-400">No notifications</p> + </div> + )} + </ScrollArea> + </PopoverContent> + </Popover> + + {/* Quick Action Buttons */} + <Dialog> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + className="hidden md:flex border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300" + > + <Ban className="mr-2 h-4 w-4" /> + Ban User + </Button> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + <DialogHeader> + <DialogTitle>Ban User</DialogTitle> + <DialogDescription className="text-gray-400">Ban a user from your Matrix rooms</DialogDescription> + </DialogHeader> + <div className="space-y-4 py-4"> + <div className="space-y-2"> + <Label htmlFor="user-id">Matrix User ID</Label> + <Input id="user-id" placeholder="@user:matrix.org" className="bg-gray-900 border-gray-800" /> + </div> + <div className="space-y-2"> + <Label htmlFor="ban-reason">Reason</Label> + <Input id="ban-reason" placeholder="Reason for the ban" className="bg-gray-900 border-gray-800" /> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" + > + Cancel + </Button> + <Button className="bg-red-600 text-white hover:bg-red-700">Ban User</Button> + </DialogFooter> + </DialogContent> + </Dialog> + + <Dialog> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + className="hidden md:flex border-orange-500 text-orange-400 hover:bg-orange-950 hover:text-orange-300" + > + <UserX className="mr-2 h-4 w-4" /> + Kick User + </Button> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + <DialogHeader> + <DialogTitle>Kick User</DialogTitle> + <DialogDescription className="text-gray-400">Kick a user from a Matrix room</DialogDescription> + </DialogHeader> + <div className="space-y-4 py-4"> + <div className="space-y-2"> + <Label htmlFor="kick-user-id">Matrix User ID</Label> + <Input id="kick-user-id" placeholder="@user:matrix.org" className="bg-gray-900 border-gray-800" /> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" + > + Cancel + </Button> + <Button className="bg-orange-600 text-white hover:bg-orange-700">Kick User</Button> + </DialogFooter> + </DialogContent> + </Dialog> + + <Button + variant="outline" + className="hidden md:flex border-purple-500 text-purple-400 hover:bg-purple-950 hover:text-purple-300" + onClick={handleLogout} + > + <LogOut className="mr-2 h-4 w-4" /> + Log Out + </Button> + <Sheet> + <SheetTrigger asChild> + <Button variant="ghost" size="icon" className="md:hidden"> + <Menu className="h-5 w-5" /> + </Button> + </SheetTrigger> + <SheetContent side="right" className="bg-gray-950 border-gray-800 p-0"> + <div className="flex flex-col h-full"> + <div className="border-b border-gray-800 p-4"> + <div className="flex items-center gap-2"> + <Shield className="h-5 w-5 text-purple-400" /> + <span className="font-medium">Draupnir4All</span> + </div> + </div> + <div className="p-4"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + className="w-full border-purple-500 text-purple-400 hover:bg-purple-950" + > + <Crown className="mr-2 h-4 w-4" /> + {selectedTeam.name} + <ChevronDown className="ml-2 h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-56 bg-gray-900 border-gray-800"> + <DropdownMenuLabel>Your Bots</DropdownMenuLabel> + <DropdownMenuSeparator className="bg-gray-800" /> + {teams.map((team) => ( + <DropdownMenuItem + key={team.id} + className={selectedTeam.id === team.id ? "bg-gray-800" : ""} + onClick={() => onTeamChange(team.id)} + > + {team.name} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + <nav className="flex flex-col p-4 gap-2"> + <Button + variant={activeTab === "overview" ? "secondary" : "ghost"} + className="justify-start" + onClick={() => setActiveTab("overview")} + > + Overview + </Button> + <Button + variant={activeTab === "reports" ? "secondary" : "ghost"} + className="justify-start" + onClick={() => setActiveTab("reports")} + > + Reports + </Button> + <Button + variant={activeTab === "bans" ? "secondary" : "ghost"} + className="justify-start" + onClick={() => setActiveTab("bans")} + > + Bans + </Button> + <Button + variant={activeTab === "analytics" ? "secondary" : "ghost"} + className="justify-start" + onClick={() => setActiveTab("analytics")} + > + Analytics + </Button> + <Button + variant={activeTab === "settings" ? "secondary" : "ghost"} + className="justify-start" + onClick={() => setActiveTab("settings")} + > + Settings + </Button> + + <Separator className="my-2" /> + + {/* Mobile Quick Actions */} + <Dialog> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + className="w-full justify-start border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300" + > + <Ban className="mr-2 h-4 w-4" /> + Ban User + </Button> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + {/* Same content as desktop dialog */} + </DialogContent> + </Dialog> + + <Dialog> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + className="w-full justify-start border-orange-500 text-orange-400 hover:bg-orange-950 hover:text-orange-300" + > + <UserX className="mr-2 h-4 w-4" /> + Kick User + </Button> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + {/* Same content as desktop dialog */} + </DialogContent> + </Dialog> + </nav> + <div className="mt-auto border-t border-gray-800 p-4"> + <Button + variant="outline" + className="w-full border-purple-500 text-purple-400 hover:bg-purple-950 hover:text-purple-300" + onClick={handleLogout} + > + <LogOut className="mr-2 h-4 w-4" /> + Log Out + </Button> + </div> + </div> + </SheetContent> + </Sheet> + </div> + </div> + </header> + ) +} diff --git a/src/components/dashboard/modals/add-protected-room.tsx b/src/app/dashboard/components/modals/add-protected-room.tsx diff --git a/src/app/dashboard/components/overview-tab.tsx b/src/app/dashboard/components/overview-tab.tsx @@ -0,0 +1,210 @@ +"use client" +import { Plus, X, AlertTriangle, Ban, CheckCircle } from "lucide-react" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { PolicyList, Report } from "../page" + +interface Team { + id: string + name: string + rooms: string[] +} + +interface OverviewTabProps { + selectedTeam: Team + policyLists: PolicyList[] + reports: Report[] +} + +export function OverviewTab({ selectedTeam, policyLists, reports }: OverviewTabProps) { + return ( + <div className="space-y-8"> + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">Protected Rooms</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{selectedTeam.rooms.length}</div> + <div className="flex justify-between items-center"> + <p className="text-xs text-gray-400">Monitored by this bot</p> + <Dialog> + <DialogTrigger asChild> + <Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-purple-400"> + <Plus className="h-3 w-3 mr-1" /> + Add Room + </Button> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + <DialogHeader> + <DialogTitle>Add Protected Room</DialogTitle> + <DialogDescription className="text-gray-400"> + Add a Matrix room to be monitored by this bot + </DialogDescription> + </DialogHeader> + <div className="space-y-4 py-4"> + <div className="space-y-2"> + <Label htmlFor="room-id">Room ID or Alias</Label> + <Input id="room-id" placeholder="#roomname:matrix.org" className="bg-gray-900 border-gray-800" /> + <p className="text-xs text-gray-400"> + Enter a room ID or alias. The bot must be invited to this room. + </p> + </div> + <div className="space-y-2"> + <Label>Protection Level</Label> + <Select defaultValue="standard"> + <SelectTrigger className="bg-gray-900 border-gray-800"> + <SelectValue placeholder="Select protection level" /> + </SelectTrigger> + <SelectContent className="bg-gray-900 border-gray-800"> + <SelectItem value="minimal">Minimal - Report only</SelectItem> + <SelectItem value="standard">Standard - Report and auto-moderate</SelectItem> + <SelectItem value="strict">Strict - Aggressive moderation</SelectItem> + </SelectContent> + </Select> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" + > + Cancel + </Button> + <Button className="bg-purple-600 text-white hover:bg-purple-700">Add Room</Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + </CardContent> + </Card> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">Active Reports</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{reports.length}</div> + <p className="text-xs text-gray-400">{reports.filter((r) => r.priority === "high").length} high priority</p> + </CardContent> + </Card> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">Bans Issued</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {policyLists.reduce((total, list) => total + list.entries.length, 0)} + </div> + <p className="text-xs text-gray-400">Across all policy lists</p> + </CardContent> + </Card> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">Bot Status</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center gap-2"> + <div className="h-3 w-3 rounded-full bg-green-500"></div> + <div className="text-sm font-medium">Online</div> + </div> + <p className="text-xs text-gray-400">Last restart: 7d ago</p> + </CardContent> + </Card> + </div> + + <div className="grid gap-4 md:grid-cols-2"> + <Card className="border-gray-800 bg-gray-950"> + <CardHeader> + <CardTitle>Recent Activity</CardTitle> + <CardDescription className="text-gray-400">Latest moderation actions</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + <div className="flex items-start gap-4"> + <div className="rounded-full bg-red-500/20 p-2"> + <Ban className="h-4 w-4 text-red-500" /> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium">User banned</p> + <p className="text-xs text-gray-400">@spammer:matrix.org was banned from all rooms</p> + <p className="text-xs text-gray-500">10 minutes ago</p> + </div> + </div> + <div className="flex items-start gap-4"> + <div className="rounded-full bg-yellow-500/20 p-2"> + <AlertTriangle className="h-4 w-4 text-yellow-500" /> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium">Report created</p> + <p className="text-xs text-gray-400">Spam detected in #support</p> + <p className="text-xs text-gray-500">1 hour ago</p> + </div> + </div> + <div className="flex items-start gap-4"> + <div className="rounded-full bg-green-500/20 p-2"> + <CheckCircle className="h-4 w-4 text-green-500" /> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium">Report resolved</p> + <p className="text-xs text-gray-400">Harassment report in #community resolved</p> + <p className="text-xs text-gray-500">3 hours ago</p> + </div> + </div> + </div> + </CardContent> + </Card> + + <Card className="border-gray-800 bg-gray-950"> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>Protected Rooms</CardTitle> + <CardDescription className="text-gray-400">Rooms monitored by this bot</CardDescription> + </div> + <Dialog> + <DialogTrigger asChild> + <Button size="sm" className="bg-purple-600 text-white hover:bg-purple-700"> + <Plus className="mr-2 h-4 w-4" /> + Add Room + </Button> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + {/* Same content as the other add room dialog */} + </DialogContent> + </Dialog> + </div> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {selectedTeam.rooms.map((room, index) => ( + <div key={index} className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 rounded-full bg-green-500"></div> + <p className="text-sm">{room}</p> + </div> + <Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-gray-400 hover:text-red-400"> + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </Button> + </div> + ))} + </div> + </CardContent> + </Card> + </div> + </div> + ) +} diff --git a/src/app/dashboard/components/reports-tab.tsx b/src/app/dashboard/components/reports-tab.tsx @@ -0,0 +1,262 @@ +"use client" + +import { useState } from "react" +import { AlertTriangle, CheckCircle, MessageSquare, UserPlus, Filter, Ban } from "lucide-react" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { BlurredText } from "@/components/blurred-text" +import { BlurredImage } from "@/components/blurred-image" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Report } from "../page"; + +interface ReportsTabProps { + reports: Report[] +} + +export function ReportsTab({ reports }: ReportsTabProps) { + const [selectedReport, setSelectedReport] = useState<string | null>(null) + const [filter, setFilter] = useState("all") + + const filteredReports = + filter === "all" + ? reports + : filter === "high" + ? reports.filter((r) => r.priority === "high") + : reports.filter((r) => r.type === filter) + + return ( + <Card className="border-gray-800 bg-gray-950"> + <CardHeader className="pb-2"> + <div className="flex items-center justify-between"> + <CardTitle>Active Reports</CardTitle> + <div className="flex items-center gap-2"> + <Select defaultValue="all" onValueChange={setFilter}> + <SelectTrigger className="h-8 w-[120px] bg-gray-900 border-gray-800"> + <SelectValue placeholder="Filter" /> + </SelectTrigger> + <SelectContent className="bg-gray-900 border-gray-800"> + <SelectItem value="all">All Reports</SelectItem> + <SelectItem value="message">Messages</SelectItem> + <SelectItem value="invite">Invites</SelectItem> + <SelectItem value="high">High Priority</SelectItem> + </SelectContent> + </Select> + </div> + </div> + <CardDescription className="text-gray-400">Reports requiring moderation action</CardDescription> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-4"> + <h3 className="text-sm font-medium text-gray-400">Report List</h3> + {filteredReports.length > 0 ? ( + filteredReports.map((report) => ( + <div + key={report.id} + className={`p-3 rounded-md border cursor-pointer transition-colors ${selectedReport === report.id + ? "bg-gray-800 border-purple-600" + : "bg-gray-900 border-gray-800 hover:border-gray-700" + }`} + onClick={() => setSelectedReport(report.id)} + > + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2"> + {report.type === "message" ? ( + <MessageSquare className="h-4 w-4 text-blue-400" /> + ) : ( + <UserPlus className="h-4 w-4 text-green-400" /> + )} + <span className="text-sm font-medium"> + {report.type === "message" ? "Message Report" : "Invite Report"} + </span> + </div> + <Badge + className={ + report.priority === "high" + ? "bg-red-900/50 text-red-300 hover:bg-red-900/70" + : "bg-yellow-900/50 text-yellow-300 hover:bg-yellow-900/70" + } + > + {report.priority} + </Badge> + </div> + <p className="text-xs text-gray-400 mb-1">{report.room}</p> + <p className="text-xs text-gray-400 mb-1"> + {report.type === "message" + ? `From: ${report.subject.sender}` + : `From: ${report.subject.sender} To: ${report.subject.target}`} + </p> + <p className="text-xs text-gray-500">{new Date(report.timestamp).toLocaleString()}</p> + </div> + )) + ) : ( + <div className="flex flex-col items-center justify-center p-8 text-center"> + <CheckCircle className="h-8 w-8 text-gray-500 mb-2" /> + <p className="text-gray-400">No active reports</p> + </div> + )} + </div> + + <div className="space-y-4"> + <h3 className="text-sm font-medium text-gray-400">Report Details</h3> + {selectedReport ? ( + (() => { + const report = reports.find((r) => r.id === selectedReport) + if (!report) return <p>Select a report to view details</p> + + return ( + <div className="space-y-4"> + <div className="p-4 rounded-md bg-gray-900 border border-gray-800"> + <div className="flex items-center justify-between mb-3"> + <h4 className="font-medium">Report Information</h4> + <Badge className="bg-purple-900/50 text-purple-300">{report.status}</Badge> + </div> + + <div className="space-y-2 text-sm"> + <div className="flex justify-between"> + <span className="text-gray-400">Reported by:</span> + <span>{report.reporter}</span> + </div> + <div className="flex justify-between"> + <span className="text-gray-400">Room:</span> + <span>{report.room}</span> + </div> + <div className="flex justify-between"> + <span className="text-gray-400">Reason:</span> + <span>{report.reason}</span> + </div> + <div className="flex justify-between"> + <span className="text-gray-400">Time:</span> + <span>{new Date(report.timestamp).toLocaleString()}</span> + </div> + </div> + </div> + + <div className="p-4 rounded-md bg-gray-900 border border-gray-800"> + <h4 className="font-medium mb-3"> + {report.type === "message" ? "Message Content" : "Invite Details"} + </h4> + + {report.type === "message" && ( + <div className="space-y-3"> + <div className="flex justify-between text-sm"> + <span className="text-gray-400">Sender:</span> + <span>{report.subject.sender}</span> + </div> + <div className="flex justify-between text-sm"> + <span className="text-gray-400">Message ID:</span> + <span className="font-mono text-xs">{report.subject.id}</span> + </div> + <div className="flex justify-between text-sm"> + <span className="text-gray-400">Time:</span> + <span>{new Date(report.subject.timestamp).toLocaleString()}</span> + </div> + + <div className="mt-4"> + <div className="flex items-center justify-between mb-2"> + <span className="text-sm text-gray-400">Content:</span> + {report.subject.content?.msgtype === "m.text" && ( + <Button variant="ghost" size="sm" className="h-6 px-2 text-gray-400"> + <Filter className="h-3 w-3 mr-1" /> + Filter + </Button> + )} + </div> + + {report.subject.content?.msgtype === "m.text" ? ( + <BlurredText text={report.subject.content?.body} /> + ) : report.subject.content?.msgtype === "m.image" ? ( + <div className="mt-2"> + <BlurredImage + src="/placeholder.svg?height=300&width=400" + alt={report.subject.content?.body} + width={400} + height={300} + /> + <p className="text-xs text-gray-500 mt-1">Image: {report.subject.content.body}</p> + </div> + ) : ( + <p className="text-sm">Unsupported content type</p> + )} + </div> + </div> + )} + + {report.type === "invite" && ( + <div className="space-y-3"> + <div className="flex justify-between text-sm"> + <span className="text-gray-400">Sender:</span> + <span>{report.subject.sender}</span> + </div> + <div className="flex justify-between text-sm"> + <span className="text-gray-400">Target:</span> + <span>{report.subject.target}</span> + </div> + <div className="flex justify-between text-sm"> + <span className="text-gray-400">Time:</span> + <span>{new Date(report.subject.timestamp).toLocaleString()}</span> + </div> + </div> + )} + </div> + + <div className="flex gap-2 mt-4"> + <Button className="bg-green-600 text-white hover:bg-green-700"> + <CheckCircle className="mr-2 h-4 w-4" /> + Resolve + </Button> + <Dialog> + <DialogTrigger asChild> + <Button + variant="outline" + className="border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300" + > + <Ban className="mr-2 h-4 w-4" /> + Ban User + </Button> + </DialogTrigger> + <DialogContent className="bg-gray-950 border-gray-800"> + <DialogHeader> + <DialogTitle>Ban User</DialogTitle> + <DialogDescription className="text-gray-400"> + Ban {report.subject.sender} from your Matrix rooms + </DialogDescription> + </DialogHeader> + {/* Ban dialog content */} + </DialogContent> + </Dialog> + {report.type === "message" && ( + <Button + variant="outline" + className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" + > + <MessageSquare className="mr-2 h-4 w-4" /> + Delete Message + </Button> + )} + </div> + </div> + ) + })() + ) : ( + <div className="flex flex-col items-center justify-center p-8 text-center"> + <AlertTriangle className="h-8 w-8 text-gray-500 mb-2" /> + <p className="text-gray-400">Select a report to view details</p> + </div> + )} + </div> + </div> + </CardContent> + </Card> + ) +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx @@ -0,0 +1,24 @@ +import type React from "react" +import { Suspense } from "react" +import { redirect } from "next/navigation" +import { getSessionUser } from "@/lib/auth" + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + // Check if the user is authenticated on the server + const user = await getSessionUser() + + // If not authenticated, redirect to login + if (!user) { + redirect("/login") + } + + return ( + <Suspense fallback={<div className="flex h-screen w-full items-center justify-center">Loading...</div>}> + {children} + </Suspense> + ) +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx @@ -1,103 +1,65 @@ -'use client'; +"use client" import { useState } from "react" -import Link from "next/link" -import { - Shield, - AlertTriangle, - CheckCircle, - Ban, - LogOut, - Bell, - Menu, - UserX, - MessageSquare, - UserPlus, - Filter, - Crown, - X, - Plus, - Home, - ChevronDown, -} from "lucide-react" - -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Textarea } from "@/components/ui/textarea" -import { BlurredText } from "@/components/blurred-text" -import { BlurredImage } from "@/components/blurred-image" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Checkbox } from "@/components/ui/checkbox" -import { mockTeams, TeamManagement } from "./team-management" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import Overview from "@/components/dashboard/overview-pane"; -import Reports from "@/components/dashboard/reports-pane"; -import Bans from "@/components/dashboard/bans-pane"; -// Mock notifications -const mockNotifications = [ - { - id: "notif1", - type: "report", - title: "New report", - message: "A new message has been reported in #general", - timestamp: "2025-04-21T15:30:00Z", - read: false, - teamId: "team1", - }, - { - id: "notif2", - type: "ban", - title: "User banned", - message: "@spammer:matrix.org has been banned by @mod1:matrix.org", - timestamp: "2025-04-21T14:45:00Z", - read: false, - teamId: "team1", - }, - { - id: "notif3", - type: "invite", - title: "Team invitation", - message: "You've been invited to join the Support Team Bot", - timestamp: "2025-04-21T12:15:00Z", - read: true, - teamId: "team2", - }, -] +import { mockTeams, Team, TeamManagement } from "./team-management" +import Bans from "./components/bans-pane" +import { AnalyticsDashboard } from "./analytics-dashboard" +import { ReportsTab } from "./components/reports-tab" +import { DashboardHeader } from "./components/dashboard-header" +import { OverviewTab } from "./components/overview-tab" + +export interface PolicyList { + id: string; + name: string; + description: string; + readOnly: boolean; + teamId: string; + entries: { + id: string, + target: string, + reason: string, + timestamp: string, + }[]; +}; + +export interface Report { + id: string; + type: string; + status: string; + priority: string; + timestamp: string; + reporter: string; + room: string; + teamId: string; + subject: { + type: string; + id?: string; + sender: string; + content?: { + msgtype: string; + body: string; + url?: string; + info?: { + mimetype: string; + w: number; + h: number; + size: number; + }; + }; + target?: string; + timestamp: string; + }; + reason: string; +}; export default function DashboardPage() { const [activeTab, setActiveTab] = useState("overview") - const [selectedReport, setSelectedReport] = useState<string | null>(null) - const [selectedPolicyList, setSelectedPolicyList] = useState("global") - const [selectedTeam, setSelectedTeam] = useState(mockTeams[0]) - const [notifications, setNotifications] = useState(mockNotifications) + const [selectedTeam, setSelectedTeam] = useState<Team>(mockTeams[0]) - // Mock data for reports - now filtered by team - const reports = [ + // Mock data for reports - filtered by team + const reports: Report[] = [ { id: "rep_1", type: "message", @@ -187,8 +149,8 @@ export default function DashboardPage() { }, ].filter((report) => report.teamId === selectedTeam.id) - // Mock data for policy lists - now filtered by team - const policyLists = [ + // Mock data for policy lists - filtered by team + const policyLists: PolicyList[] = [ { id: "global", name: "Global Ban List", @@ -259,433 +221,23 @@ export default function DashboardPage() { }, ].filter((list) => list.teamId === selectedTeam.id) - const selectedList = policyLists.find((list) => list.id === selectedPolicyList) || policyLists[0] || { entries: [] } - - const teamNotifications = notifications.filter((n) => n.teamId === selectedTeam.id || !n.teamId) - const unreadCount = teamNotifications.filter((n) => !n.read).length const handleTeamChange = (teamId: string) => { const team = mockTeams.find((t) => t.id === teamId) if (team) { setSelectedTeam(team) - // Reset selected report and policy list when changing teams - setSelectedReport(null) - if (policyLists.length > 0) { - setSelectedPolicyList(policyLists[0].id) - } } } - const markNotificationAsRead = (id: string) => { - setNotifications(notifications.map((n) => (n.id === id ? { ...n, read: true } : n))) - } - - const clearAllNotifications = () => { - setNotifications(notifications.map((n) => ({ ...n, read: true }))) - } - - const deleteNotification = (id: string) => { - setNotifications(notifications.filter((n) => n.id !== id)) - } - return ( <div className="flex min-h-screen flex-col bg-black text-white"> - <header className="sticky top-0 z-10 border-b border-gray-800 bg-black/80 backdrop-blur-sm"> - <div className="container flex h-16 items-center justify-between px-4 md:px-6"> - <div className="flex items-center gap-4"> - <Link href="/" className="flex items-center gap-2"> - <Shield className="h-6 w-6 text-purple-400" /> - <span className="text-xl font-bold tracking-tighter">Draupnir4All</span> - </Link> - - {/* Global Team Selector */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="outline" className="border-purple-500 text-purple-400 hover:bg-purple-950"> - <Crown className="mr-2 h-4 w-4" /> - {selectedTeam.name} - <ChevronDown className="ml-2 h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent className="w-56 bg-gray-900 border-gray-800"> - <DropdownMenuLabel>Your Bots</DropdownMenuLabel> - <DropdownMenuSeparator className="bg-gray-800" /> - {mockTeams.map((team) => ( - <DropdownMenuItem - key={team.id} - className={selectedTeam.id === team.id ? "bg-gray-800" : ""} - onClick={() => handleTeamChange(team.id)} - > - {team.name} - </DropdownMenuItem> - ))} - <DropdownMenuSeparator className="bg-gray-800" /> - <Dialog> - <DialogTrigger asChild> - <DropdownMenuItem> - <Plus className="mr-2 h-4 w-4" /> - Create New Bot - </DropdownMenuItem> - </DialogTrigger> - <DialogContent className="bg-gray-950 border-gray-800"> - <DialogHeader> - <DialogTitle>Create New Bot</DialogTitle> - <DialogDescription className="text-gray-400"> - Set up a new Draupnir bot for a different community - </DialogDescription> - </DialogHeader> - <div className="space-y-4 py-4"> - <div className="space-y-2"> - <Label htmlFor="bot-name">Bot Name</Label> - <Input id="bot-name" placeholder="My Community Bot" className="bg-gray-900 border-gray-800" /> - </div> - <div className="space-y-2"> - <Label htmlFor="bot-description">Description</Label> - <Input - id="bot-description" - placeholder="Moderation for my community" - className="bg-gray-900 border-gray-800" - /> - </div> - </div> - <DialogFooter> - <Button - variant="outline" - className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" - > - Cancel - </Button> - <Button className="bg-purple-600 text-white hover:bg-purple-700">Create Bot</Button> - </DialogFooter> - </DialogContent> - </Dialog> - </DropdownMenuContent> - </DropdownMenu> - </div> - - <div className="flex items-center gap-4"> - {/* Notifications */} - <Popover> - <PopoverTrigger asChild> - <Button variant="ghost" size="icon" className="relative text-gray-400 hover:text-white"> - <Bell className="h-5 w-5" /> - {unreadCount > 0 && ( - <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-medium text-white"> - {unreadCount} - </span> - )} - </Button> - </PopoverTrigger> - <PopoverContent className="w-80 p-0 bg-gray-900 border-gray-800"> - <div className="flex items-center justify-between border-b border-gray-800 p-3"> - <h3 className="font-medium">Notifications</h3> - <div className="flex items-center gap-1"> - <Button - variant="ghost" - size="sm" - className="h-7 px-2 text-xs text-gray-400 hover:text-white" - onClick={clearAllNotifications} - > - Clear all - </Button> - </div> - </div> - <ScrollArea className="h-[300px]"> - {teamNotifications.length > 0 ? ( - <div className="space-y-1 p-1"> - {teamNotifications.map((notification) => ( - <div - key={notification.id} - className={`flex items-start p-3 rounded-md ${notification.read ? "bg-gray-900" : "bg-gray-800"}`} - onClick={() => markNotificationAsRead(notification.id)} - > - <div - className={`rounded-full p-1.5 mr-3 ${notification.type === "report" - ? "bg-yellow-500/20" - : notification.type === "ban" - ? "bg-red-500/20" - : "bg-blue-500/20" - }`} - > - {notification.type === "report" ? ( - <AlertTriangle className="h-3.5 w-3.5 text-yellow-500" /> - ) : notification.type === "ban" ? ( - <Ban className="h-3.5 w-3.5 text-red-500" /> - ) : ( - <UserPlus className="h-3.5 w-3.5 text-blue-500" /> - )} - </div> - <div className="flex-1 min-w-0"> - <div className="flex items-center justify-between"> - <p className="text-sm font-medium">{notification.title}</p> - <Button - variant="ghost" - size="sm" - className="h-6 w-6 p-0 text-gray-500 hover:text-gray-300" - onClick={(e) => { - e.stopPropagation() - deleteNotification(notification.id) - }} - > - <X className="h-3 w-3" /> - <span className="sr-only">Dismiss</span> - </Button> - </div> - <p className="text-xs text-gray-400 line-clamp-2">{notification.message}</p> - <p className="text-xs text-gray-500 mt-1"> - {new Date(notification.timestamp).toLocaleString()} - </p> - </div> - </div> - ))} - </div> - ) : ( - <div className="flex flex-col items-center justify-center h-full p-6 text-center"> - <Bell className="h-8 w-8 text-gray-600 mb-2" /> - <p className="text-gray-400">No notifications</p> - </div> - )} - </ScrollArea> - </PopoverContent> - </Popover> - - {/* Quick Action Buttons */} - <Dialog> - <DialogTrigger asChild> - <Button - variant="outline" - size="sm" - className="hidden md:flex border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300" - > - <Ban className="mr-2 h-4 w-4" /> - Ban User - </Button> - </DialogTrigger> - <DialogContent className="bg-gray-950 border-gray-800"> - <DialogHeader> - <DialogTitle>Ban User</DialogTitle> - <DialogDescription className="text-gray-400">Ban a user from your Matrix rooms</DialogDescription> - </DialogHeader> - <div className="space-y-4 py-4"> - <div className="space-y-2"> - <Label htmlFor="user-id">Matrix User ID</Label> - <Input id="user-id" placeholder="@user:matrix.org" className="bg-gray-900 border-gray-800" /> - </div> - <div className="space-y-2"> - <Label htmlFor="ban-reason">Reason</Label> - <Textarea - id="ban-reason" - placeholder="Reason for the ban" - className="bg-gray-900 border-gray-800" - /> - </div> - <div className="space-y-2"> - <Label>Policy Lists</Label> - <div className="space-y-2 mt-1"> - {policyLists - .filter((list) => !list.readOnly) - .map((list) => ( - <div key={list.id} className="flex items-center space-x-2"> - <Checkbox id={`list-${list.id}`} /> - <Label htmlFor={`list-${list.id}`} className="font-normal"> - {list.name} - </Label> - </div> - ))} - </div> - </div> - <div className="space-y-2"> - <p className="text-xs text-gray-400"> - Bans are applied to all protected rooms monitored by this bot. - </p> - </div> - </div> - <DialogFooter> - <Button - variant="outline" - className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" - > - Cancel - </Button> - <Button className="bg-red-600 text-white hover:bg-red-700">Ban User</Button> - </DialogFooter> - </DialogContent> - </Dialog> - - <Dialog> - <DialogTrigger asChild> - <Button - variant="outline" - size="sm" - className="hidden md:flex border-orange-500 text-orange-400 hover:bg-orange-950 hover:text-orange-300" - > - <UserX className="mr-2 h-4 w-4" /> - Kick User - </Button> - </DialogTrigger> - <DialogContent className="bg-gray-950 border-gray-800"> - <DialogHeader> - <DialogTitle>Kick User</DialogTitle> - <DialogDescription className="text-gray-400">Kick a user from a Matrix room</DialogDescription> - </DialogHeader> - <div className="space-y-4 py-4"> - <div className="space-y-2"> - <Label htmlFor="kick-user-id">Matrix User ID</Label> - <Input id="kick-user-id" placeholder="@user:matrix.org" className="bg-gray-900 border-gray-800" /> - </div> - <div className="space-y-2"> - <Label htmlFor="room-id">Room</Label> - <Select defaultValue="general"> - <SelectTrigger className="bg-gray-900 border-gray-800"> - <SelectValue placeholder="Select a room" /> - </SelectTrigger> - <SelectContent className="bg-gray-900 border-gray-800"> - {selectedTeam.rooms.map((room) => ( - <SelectItem key={room} value={room.replace("#", "").split(":")[0]}> - {room} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - <div className="space-y-2"> - <Label htmlFor="kick-reason">Reason</Label> - <Textarea - id="kick-reason" - placeholder="Reason for the kick" - className="bg-gray-900 border-gray-800" - /> - </div> - </div> - <DialogFooter> - <Button - variant="outline" - className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" - > - Cancel - </Button> - <Button className="bg-orange-600 text-white hover:bg-orange-700">Kick User</Button> - </DialogFooter> - </DialogContent> - </Dialog> - - <Link href="/"> - <Button - variant="outline" - className="hidden md:flex border-purple-500 text-purple-400 hover:bg-purple-950 hover:text-purple-300" - > - <LogOut className="mr-2 h-4 w-4" /> - Log Out - </Button> - </Link> - <Sheet> - <SheetTrigger asChild> - <Button variant="ghost" size="icon" className="md:hidden"> - <Menu className="h-5 w-5" /> - </Button> - </SheetTrigger> - <SheetContent side="right" className="bg-gray-950 border-gray-800 p-0"> - <div className="flex flex-col h-full"> - <div className="border-b border-gray-800 p-4"> - <div className="flex items-center gap-2"> - <Shield className="h-5 w-5 text-purple-400" /> - <span className="font-medium">Draupnir4All</span> - </div> - </div> - <div className="p-4"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - className="w-full border-purple-500 text-purple-400 hover:bg-purple-950" - > - <Crown className="mr-2 h-4 w-4" /> - {selectedTeam.name} - <ChevronDown className="ml-2 h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent className="w-56 bg-gray-900 border-gray-800"> - <DropdownMenuLabel>Your Bots</DropdownMenuLabel> - <DropdownMenuSeparator className="bg-gray-800" /> - {mockTeams.map((team) => ( - <DropdownMenuItem - key={team.id} - className={selectedTeam.id === team.id ? "bg-gray-800" : ""} - onClick={() => handleTeamChange(team.id)} - > - {team.name} - </DropdownMenuItem> - ))} - <DropdownMenuSeparator className="bg-gray-800" /> - <DropdownMenuItem> - <Plus className="mr-2 h-4 w-4" /> - Create New Bot - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> - <nav className="flex flex-col p-4 gap-2"> - <Link - className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${activeTab === "overview" ? "bg-gray-900 text-white" : "text-gray-400 hover:text-white"}`} - href="#" - onClick={() => setActiveTab("overview")} - > - <Home className="h-4 w-4" /> - Dashboard - </Link> - - <Separator className="my-2" /> - - {/* Mobile Quick Actions */} - <Dialog> - <DialogTrigger asChild> - <Button - variant="outline" - size="sm" - className="w-full justify-start border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300" - > - <Ban className="mr-2 h-4 w-4" /> - Ban User - </Button> - </DialogTrigger> - <DialogContent className="bg-gray-950 border-gray-800"> - {/* Same content as desktop dialog */} - </DialogContent> - </Dialog> - - <Dialog> - <DialogTrigger asChild> - <Button - variant="outline" - size="sm" - className="w-full justify-start border-orange-500 text-orange-400 hover:bg-orange-950 hover:text-orange-300" - > - <UserX className="mr-2 h-4 w-4" /> - Kick User - </Button> - </DialogTrigger> - <DialogContent className="bg-gray-950 border-gray-800"> - {/* Same content as desktop dialog */} - </DialogContent> - </Dialog> - </nav> - <div className="mt-auto border-t border-gray-800 p-4"> - <Link href="/"> - <Button - variant="outline" - className="w-full border-purple-500 text-purple-400 hover:bg-purple-950 hover:text-purple-300" - > - <LogOut className="mr-2 h-4 w-4" /> - Log Out - </Button> - </Link> - </div> - </div> - </SheetContent> - </Sheet> - </div> - </div> - </header> + <DashboardHeader + selectedTeam={selectedTeam} + onTeamChange={handleTeamChange} + activeTab={activeTab} + setActiveTab={setActiveTab} + teams={mockTeams} + /> <main className="flex-1 container px-4 py-8 md:px-6 md:py-12"> <Tabs defaultValue="overview" value={activeTab} onValueChange={setActiveTab} className="space-y-8"> <div className="flex items-center justify-between"> @@ -694,19 +246,23 @@ export default function DashboardPage() { <TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="reports">Reports</TabsTrigger> <TabsTrigger value="bans">Bans</TabsTrigger> + <TabsTrigger value="analytics">Analytics</TabsTrigger> <TabsTrigger value="settings">Settings</TabsTrigger> </TabsList> </div> - <TabsContent value="overview" className="space-y-8"> - <Overview selectedTeam={selectedTeam} reports={reports} policyLists={policyLists} /> + <TabsContent value="overview"> + <OverviewTab selectedTeam={selectedTeam} policyLists={policyLists} reports={reports} /> + </TabsContent> + <TabsContent value="reports"> + <ReportsTab reports={reports} /> </TabsContent> - <TabsContent value="reports" className="space-y-4"> - <Reports selectedReport={selectedReport} reports={reports} setSelectedReport={setSelectedReport} /> + <TabsContent value="bans"> + <Bans policyLists={policyLists} /> </TabsContent> - <TabsContent value="bans" className="space-y-4"> - <Bans selectedList={selectedList} selectedPolicyList={selectedPolicyList} policyLists={policyLists} setSelectedPolicyList={setSelectedPolicyList} /> + <TabsContent value="analytics"> + <AnalyticsDashboard selectedTeam={selectedTeam} /> </TabsContent> - <TabsContent value="settings" className="space-y-4"> + <TabsContent value="settings"> <TeamManagement selectedTeam={selectedTeam} onTeamChange={handleTeamChange} /> </TabsContent> </Tabs> diff --git a/src/app/dashboard/team-management.tsx b/src/app/dashboard/team-management.tsx @@ -28,7 +28,7 @@ import { } from "@/components/ui/dropdown-menu" import { ScrollArea } from "@/components/ui/scroll-area" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import AddProtectedRoom from "@/components/dashboard/modals/add-protected-room"; +import AddProtectedRoom from "./components/modals/add-protected-room"; export interface Team { id: string; diff --git a/src/app/layout.tsx b/src/app/layout.tsx @@ -1,7 +1,8 @@ import type React from "react" -import "./globals.css" +import "@/app/globals.css" import { Inter } from "next/font/google" import { ThemeProvider } from "@/components/theme-provider" +import { SessionProvider } from "@/contexts/session-context" const inter = Inter({ subsets: ["latin"] }) @@ -19,9 +20,9 @@ export default function RootLayout({ <html lang="en" className="dark" style={{ colorScheme: "dark" }}> <body className={inter.className}> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> - {children} + <SessionProvider>{children}</SessionProvider> </ThemeProvider> </body> - </html > + </html> ) } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx @@ -1,7 +1,6 @@ "use client" import { useState } from "react" -import { useRouter } from "next/navigation" import Link from "next/link" import { Shield, ArrowRight, Loader2 } from "lucide-react" @@ -10,11 +9,12 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { MatrixLoginForm } from "@/components/matrix-login-form" +import { useSession } from "@/contexts/session-context" export default function LoginPage() { - const router = useRouter() + const { login, isLoading } = useSession() const [matrixId, setMatrixId] = useState("") - const [step, setStep] = useState<"initial" | "login" | "verifying" | "error">("initial") + const [step, setStep] = useState<"initial" | "login" | "error">("initial") const [errorMessage, setErrorMessage] = useState("") const startMatrixAuth = () => { @@ -23,26 +23,12 @@ export default function LoginPage() { } const handleLoginSuccess = async (token: string) => { - setStep("verifying") - try { - // In a real implementation, send the token to your backend for verification - // const response = await fetch('/api/verify-matrix-token', { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify({ matrixId, token }) - // }) - // const data = await response.json() - // if (!data.success) throw new Error(data.error || 'Verification failed') - - // For demo purposes, simulate a successful verification - await new Promise((resolve) => setTimeout(resolve, 1500)) - - // Redirect to dashboard after successful login - router.push("/dashboard") + // Use the login function from session context + await login(matrixId) } catch (error) { - console.error("Token verification failed", error) - setErrorMessage(error instanceof Error ? error.message : "Verification failed") + console.error("Login failed", error) + setErrorMessage(error instanceof Error ? error.message : "Authentication failed") setStep("error") } } @@ -84,7 +70,7 @@ export default function LoginPage() { <MatrixLoginForm matrixId={matrixId} onSuccess={handleLoginSuccess} onCancel={() => setStep("initial")} /> )} - {step === "verifying" && ( + {isLoading && ( <div className="flex flex-col items-center justify-center py-6 space-y-4"> <Loader2 className="h-8 w-8 animate-spin text-purple-400" /> <p className="text-gray-300">Logging in...</p> @@ -126,7 +112,7 @@ export default function LoginPage() { </> )} - {step === "verifying" && ( + {isLoading && ( <Button className="w-full" disabled> Please wait... </Button> diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx @@ -1,7 +1,6 @@ "use client" import { useState } from "react" -import { useRouter } from "next/navigation" import Link from "next/link" import { Shield, ArrowRight, Loader2 } from "lucide-react" @@ -10,11 +9,12 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { MatrixLoginForm } from "@/components/matrix-login-form" +import { useSession } from "@/contexts/session-context" export default function RegisterPage() { - const router = useRouter() + const { register, isLoading } = useSession() const [matrixId, setMatrixId] = useState("") - const [step, setStep] = useState<"initial" | "login" | "verifying" | "error">("initial") + const [step, setStep] = useState<"initial" | "login" | "error">("initial") const [errorMessage, setErrorMessage] = useState("") const startMatrixAuth = () => { @@ -23,26 +23,12 @@ export default function RegisterPage() { } const handleLoginSuccess = async (token: string) => { - setStep("verifying") - try { - // In a real implementation, send the token to your backend for verification - // const response = await fetch('/api/verify-matrix-token', { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify({ matrixId, token }) - // }) - // const data = await response.json() - // if (!data.success) throw new Error(data.error || 'Verification failed') - - // For demo purposes, simulate a successful verification - await new Promise((resolve) => setTimeout(resolve, 1500)) - - // Redirect to dashboard after successful registration - router.push("/dashboard") + // Use the register function from session context + await register(matrixId) } catch (error) { - console.error("Token verification failed", error) - setErrorMessage(error instanceof Error ? error.message : "Verification failed") + console.error("Registration failed", error) + setErrorMessage(error instanceof Error ? error.message : "Registration failed") setStep("error") } } @@ -86,7 +72,7 @@ export default function RegisterPage() { <MatrixLoginForm matrixId={matrixId} onSuccess={handleLoginSuccess} onCancel={() => setStep("initial")} /> )} - {step === "verifying" && ( + {isLoading && ( <div className="flex flex-col items-center justify-center py-6 space-y-4"> <Loader2 className="h-8 w-8 animate-spin text-purple-400" /> <p className="text-gray-300">Registering your account...</p> @@ -120,7 +106,7 @@ export default function RegisterPage() { </Button> )} - {step === "verifying" && ( + {isLoading && ( <Button className="w-full" disabled> Please wait... </Button> diff --git a/src/components/analytics/bar-chart.tsx b/src/components/analytics/bar-chart.tsx @@ -0,0 +1,66 @@ +"use client" + +interface BarChartItem { + label: string + values: { + value: number + color: string + label: string + }[] +} + +interface BarChartProps { + data: BarChartItem[] + height?: number + className?: string +} + +export function BarChart({ data, height = 200, className = "" }: BarChartProps) { + // Find the maximum value across all data points for scaling + const maxValue = Math.max(...data.flatMap((item) => item.values.map((v) => v.value))) + + return ( + <div className={`w-full ${className}`}> + <div className={`h-[${height}px] w-full`}> + <div className="flex h-full items-end gap-2"> + {data.map((item, index) => ( + <div key={index} className="group relative flex w-full flex-col items-center"> + <div className="relative flex h-full w-full flex-col justify-end"> + {item.values.map((value, valueIndex) => { + const barHeight = (value.value / maxValue) * 100 + return ( + <div + key={valueIndex} + className={`w-full rounded-t transition-all group-hover:opacity-90 ${valueIndex > 0 ? "mt-0.5" : ""}`} + style={{ + height: `${barHeight}%`, + backgroundColor: value.color, + }} + ></div> + ) + })} + </div> + <div className="absolute bottom-0 left-0 right-0 h-0 overflow-hidden bg-gray-800 opacity-0 transition-all group-hover:h-auto group-hover:opacity-100 z-10 rounded-md"> + <div className="p-2 text-center text-xs"> + <div className="font-medium">{item.label}</div> + {item.values.map((value, valueIndex) => ( + <div key={valueIndex} style={{ color: value.color }}> + {value.label}: {value.value} + </div> + ))} + </div> + </div> + </div> + ))} + </div> + </div> + <div className="mt-2 flex w-full justify-between"> + {data.map((item, index) => ( + <div key={index} className="text-center text-xs text-gray-400"> + {item.label} + </div> + ))} + </div> + </div> + ) +} diff --git a/src/components/analytics/heatmap.tsx b/src/components/analytics/heatmap.tsx @@ -0,0 +1,100 @@ +"use client" + +import { Calendar } from "lucide-react" + +interface HeatmapDay { + date: string + count: number +} + +interface HeatmapProps { + data: HeatmapDay[] + title: string + colorIntensityLabel?: string + className?: string +} + +export function Heatmap({ data, title, colorIntensityLabel = "Activity", className = "" }: HeatmapProps) { + // Get intensity color based on count + const getColor = (count: number) => { + if (count === 0) return "bg-gray-900" + if (count < 3) return "bg-purple-950" + if (count < 6) return "bg-purple-900" + if (count < 9) return "bg-purple-800" + return "bg-purple-600" + } + + // Group data by week + const weeks: HeatmapDay[][] = [] + let currentWeek: HeatmapDay[] = [] + + // Process data to create weeks (7 days per week) + data.forEach((day, index) => { + currentWeek.push(day) + if (currentWeek.length === 7 || index === data.length - 1) { + weeks.push([...currentWeek]) + currentWeek = [] + } + }) + + return ( + <div className={`space-y-2 ${className}`}> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-gray-400" /> + <h3 className="text-sm font-medium">{title}</h3> + </div> + <div className="flex items-center gap-1 text-xs text-gray-400"> + <span>Less</span> + <div className="flex gap-1"> + <div className="h-3 w-3 rounded-sm bg-gray-900"></div> + <div className="h-3 w-3 rounded-sm bg-purple-950"></div> + <div className="h-3 w-3 rounded-sm bg-purple-900"></div> + <div className="h-3 w-3 rounded-sm bg-purple-800"></div> + <div className="h-3 w-3 rounded-sm bg-purple-600"></div> + </div> + <span>More</span> + </div> + </div> + + <div className="flex gap-2"> + {/* Days of week labels */} + <div className="flex flex-col pt-6 pr-2 text-xs text-gray-500"> + <div className="h-[18px] mb-[2px]">Mon</div> + <div className="h-[18px] mb-[2px]">Tue</div> + <div className="h-[18px] mb-[2px]">Wed</div> + <div className="h-[18px] mb-[2px]">Thu</div> + <div className="h-[18px] mb-[2px]">Fri</div> + <div className="h-[18px] mb-[2px]">Sat</div> + <div className="h-[18px]">Sun</div> + </div> + + {/* Heatmap grid */} + <div className="overflow-x-auto"> + <div className="flex gap-1 min-w-[650px]"> + {weeks.map((week, weekIndex) => ( + <div key={weekIndex} className="flex flex-col gap-[2px]"> + {week.map((day, dayIndex) => ( + <div + key={`${weekIndex}-${dayIndex}`} + className={`h-[18px] w-[18px] rounded-sm ${getColor(day.count)}`} + title={`${day.date}: ${day.count} ${colorIntensityLabel}`} + ></div> + ))} + </div> + ))} + </div> + + {/* Month labels */} + <div className="flex text-xs text-gray-500 mt-1 min-w-[650px]"> + {["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].map((month, i) => ( + <div key={i} className="flex-1"> + {month} + </div> + ))} + </div> + </div> + </div> + </div> + ) +} diff --git a/src/components/analytics/horizontal-bar-chart.tsx b/src/components/analytics/horizontal-bar-chart.tsx @@ -0,0 +1,46 @@ +"use client" + +interface HorizontalBarItem { + label: string + value: number + color?: string + secondaryLabel?: string +} + +interface HorizontalBarChartProps { + data: HorizontalBarItem[] + maxValue?: number + color?: string + className?: string +} + +export function HorizontalBarChart({ data, maxValue, color = "#ef4444", className = "" }: HorizontalBarChartProps) { + // If maxValue is not provided, calculate it from the data + const calculatedMax = maxValue || Math.max(...data.map((item) => item.value)) + + return ( + <div className={`space-y-4 ${className}`}> + {data.map((item, index) => { + const percentage = (item.value / calculatedMax) * 100 + + return ( + <div key={index} className="space-y-1"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">{item.label}</span> + <span className="text-xs text-gray-400">{item.secondaryLabel || `${item.value}`}</span> + </div> + <div className="h-2 w-full overflow-hidden rounded-full bg-gray-900"> + <div + className="h-full rounded-full transition-all duration-300 hover:opacity-80" + style={{ + width: `${percentage}%`, + backgroundColor: item.color || color, + }} + ></div> + </div> + </div> + ) + })} + </div> + ) +} diff --git a/src/components/analytics/pie-chart.tsx b/src/components/analytics/pie-chart.tsx @@ -0,0 +1,147 @@ +"use client" + +import { useState } from "react" +import { PieChartIcon } from "lucide-react" + +interface PieChartItem { + id: string | number + label: string + value: number + color: string +} + +interface PieChartProps { + data: PieChartItem[] + centerLabel?: string + className?: string +} + +interface PieSlice { + id: string | number; + d: string; + fill: string; + percentage: number; + midAngle: number; + hoverDx: number; + hoverDy: number; + label: string; + value: number; + index: number; +} + +export function PieChart({ + data, + centerLabel = "Distribution", + className = "", +}: PieChartProps) { + const [hoveredSlice, setHoveredSlice] = useState<number | null>(null) + + // Calculate total for percentages + const total = data.reduce((sum, item) => sum + item.value, 0) + + // Generate pie slices + const { slices } = data.reduce( + (acc, item, i) => { + const startAngle = acc.offset + const angle = (item.value / total) * 360 + const endAngle = startAngle + angle + const largeArcFlag = angle > 180 ? 1 : 0 + + // Calculate coordinates + const startX = 50 + 45 * Math.cos((startAngle * Math.PI) / 180) + const startY = 50 + 45 * Math.sin((startAngle * Math.PI) / 180) + const endX = 50 + 45 * Math.cos((endAngle * Math.PI) / 180) + const endY = 50 + 45 * Math.sin((endAngle * Math.PI) / 180) + + // Calculate midpoint angle for hover direction + const midAngle = startAngle + angle / 2 + + // Calculate hover translation direction + const hoverDx = 5 * Math.cos((midAngle * Math.PI) / 180) + const hoverDy = 5 * Math.sin((midAngle * Math.PI) / 180) + + const d = [`M 50 50`, `L ${startX} ${startY}`, `A 45 45 0 ${largeArcFlag} 1 ${endX} ${endY}`, `Z`].join(" ") + + const percentage = Math.round((item.value / total) * 100) + + acc.slices.push({ + id: item.id, + d, + fill: item.color, + percentage, + midAngle, + hoverDx, + hoverDy, + label: item.label, + value: item.value, + index: i, + } as PieSlice) + + acc.offset = endAngle + return acc + }, + { slices: [] as PieSlice[], offset: 0 }, + ) + + return ( + <div className={`flex flex-col ${className}`}> + <div className="flex h-[220px] items-center justify-center"> + <div className="relative h-[180px] w-[180px]"> + {/* Pie chart visualization */} + <div className="relative"> + <svg viewBox="0 0 100 100" className="h-full w-full -rotate-90"> + {slices.map((slice, i) => ( + <path + key={slice.id} + d={slice.d} + fill={slice.fill} + stroke="#111" + strokeWidth="0.5" + className="transition-transform duration-200 cursor-pointer hover:opacity-90" + style={{ + transform: hoveredSlice === i ? `translate(${slice.hoverDx}px, ${slice.hoverDy}px)` : "none", + }} + onMouseEnter={() => setHoveredSlice(i)} + onMouseLeave={() => setHoveredSlice(null)} + /> + ))} + </svg> + + {/* Center circle */} + <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> + <div className="flex h-[100px] w-[100px] flex-col items-center justify-center rounded-full bg-gray-950"> + <PieChartIcon className="h-5 w-5 text-gray-400" /> + <span className="text-xs text-gray-400">{centerLabel}</span> + </div> + </div> + </div> + + {/* Tooltip container - positioned outside the SVG */} + {hoveredSlice !== null && ( + <div className="absolute top-0 left-0 w-full"> + <div className="absolute top-[-40px] left-1/2 transform -translate-x-1/2 bg-gray-800 text-white text-xs rounded px-2 py-1 z-50 whitespace-nowrap"> + {slices[hoveredSlice].label}: {slices[hoveredSlice].value} ({slices[hoveredSlice].percentage}%) + </div> + </div> + )} + </div> + </div> + <div className="mt-4 grid grid-cols-2 gap-2"> + {slices.map((slice, index) => ( + <div + key={slice.id} + className="flex items-center gap-2 p-1 rounded hover:bg-gray-900 cursor-pointer transition-colors" + onMouseEnter={() => setHoveredSlice(index)} + onMouseLeave={() => setHoveredSlice(null)} + > + <div className="h-3 w-3 rounded-full" style={{ backgroundColor: slice.fill }}></div> + <div className="flex flex-1 items-center justify-between"> + <span className="text-xs">{slice.label}</span> + <span className="text-xs text-gray-400">{slice.value}</span> + </div> + </div> + ))} + </div> + </div> + ) +} diff --git a/src/components/dashboard/bans-pane.tsx b/src/components/dashboard/bans-pane.tsx @@ -1,209 +0,0 @@ -import { Checkbox } from "../ui/checkbox" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../ui/dialog"; -import { ScrollArea } from "../ui/scroll-area" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" -import { Ban, Badge } from "lucide-react"; -import { Button } from "../ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; -import { Input } from "../ui/input"; -import { Textarea } from "../ui/textarea"; -import { Label } from "../ui/label" - -interface BansProps { - policyLists: any[]; - selectedPolicyList: string | null; - setSelectedPolicyList: (id: string) => void; - selectedList: any; -} - -export default function Bans({ policyLists, selectedPolicyList, setSelectedPolicyList, selectedList }: BansProps) { - return ( - <Card className="border-gray-800 bg-gray-950"> - <CardHeader className="pb-2"> - <div className="flex items-center justify-between"> - <CardTitle>Policy Lists</CardTitle> - <Dialog> - <DialogTrigger asChild> - <Button size="sm" className="bg-purple-600 text-white hover:bg-purple-700"> - <Ban className="mr-2 h-4 w-4" /> - Add Ban - </Button> - </DialogTrigger> - <DialogContent className="bg-gray-950 border-gray-800"> - <DialogHeader> - <DialogTitle>Add New Ban</DialogTitle> - <DialogDescription className="text-gray-400"> - Ban a user or server from your Matrix rooms - </DialogDescription> - </DialogHeader> - <div className="space-y-4 py-4"> - <div className="space-y-2"> - <Label htmlFor="ban-type">Ban Type</Label> - <Select defaultValue="user"> - <SelectTrigger className="bg-gray-900 border-gray-800"> - <SelectValue placeholder="Select ban type" /> - </SelectTrigger> - <SelectContent className="bg-gray-900 border-gray-800"> - <SelectItem value="user">User</SelectItem> - <SelectItem value="server">Server</SelectItem> - <SelectItem value="room">Room</SelectItem> - </SelectContent> - </Select> - </div> - <div className="space-y-2"> - <Label htmlFor="ban-target">Target</Label> - <Input - id="ban-target" - placeholder="@user:matrix.org" - className="bg-gray-900 border-gray-800" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="ban-reason">Reason</Label> - <Textarea - id="ban-reason" - placeholder="Reason for the ban" - className="bg-gray-900 border-gray-800" - /> - </div> - <div className="space-y-2"> - <Label>Policy Lists</Label> - <div className="space-y-2 mt-1"> - {policyLists - .filter((list) => !list.readOnly) - .map((list) => ( - <div key={list.id} className="flex items-center space-x-2"> - <Checkbox id={`list-${list.id}`} /> - <Label htmlFor={`list-${list.id}`} className="font-normal"> - {list.name} - </Label> - </div> - ))} - </div> - </div> - <div className="space-y-2"> - <p className="text-xs text-gray-400"> - Bans are applied to all protected rooms monitored by this bot. - </p> - </div> - </div> - <DialogFooter> - <Button - variant="outline" - className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" - > - Cancel - </Button> - <Button className="bg-red-600 text-white hover:bg-red-700">Add Ban</Button> - </DialogFooter> - </DialogContent> - </Dialog> - </div> - <CardDescription className="text-gray-400">Manage ban lists across your Matrix rooms</CardDescription> - </CardHeader> - <CardContent> - <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> - <div className="space-y-4"> - <h3 className="text-sm font-medium text-gray-400">Policy Lists</h3> - <div className="space-y-2"> - {policyLists.length > 0 ? ( - policyLists.map((list) => ( - <div - key={list.id} - className={`p-3 rounded-md border cursor-pointer transition-colors ${selectedPolicyList === list.id - ? "bg-gray-800 border-purple-600" - : "bg-gray-900 border-gray-800 hover:border-gray-700" - }`} - onClick={() => setSelectedPolicyList(list.id)} - > - <div className="flex items-center justify-between mb-1"> - <span className="font-medium">{list.name}</span> - {list.readOnly ? ( - <Badge className="bg-gray-800 text-gray-400">Read Only</Badge> - ) : ( - <Badge className="bg-green-900/50 text-green-300">Editable</Badge> - )} - </div> - <p className="text-xs text-gray-400">{list.description}</p> - <p className="text-xs text-gray-500 mt-1">{list.entries.length} entries</p> - </div> - )) - ) : ( - <div className="flex flex-col items-center justify-center p-8 text-center"> - <Ban className="h-8 w-8 text-gray-500 mb-2" /> - <p className="text-gray-400">No policy lists available</p> - </div> - )} - </div> - </div> - - <div className="md:col-span-2 space-y-4"> - <div className="flex items-center justify-between"> - <h3 className="text-sm font-medium text-gray-400"> - {selectedList.name || "Policy List"} Entries - </h3> - <div className="flex items-center gap-2"> - <Input placeholder="Search entries..." className="h-8 w-[200px] bg-gray-900 border-gray-800" /> - {selectedList && !selectedList.readOnly && ( - <Dialog> - <DialogTrigger asChild> - <Button size="sm" className="bg-purple-600 text-white hover:bg-purple-700"> - Add Entry - </Button> - </DialogTrigger> - <DialogContent className="bg-gray-950 border-gray-800"> - {/* Add entry dialog content */} - </DialogContent> - </Dialog> - )} - </div> - </div> - - <ScrollArea className="h-[400px] rounded-md border border-gray-800"> - <div className="p-4 space-y-3"> - {selectedList && selectedList.entries && selectedList.entries.length > 0 ? ( - selectedList.entries.map((entry) => ( - <div key={entry.id} className="p-3 rounded-md bg-gray-900 border border-gray-800"> - <div className="flex items-start justify-between"> - <div> - <p className="font-medium">{entry.target}</p> - <p className="text-sm text-gray-400 mt-1">{entry.reason}</p> - <p className="text-xs text-gray-500 mt-1"> - {new Date(entry.timestamp).toLocaleString()} - </p> - </div> - {!selectedList.readOnly && ( - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 text-gray-400 hover:text-red-400" - > - <Ban className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </Button> - )} - </div> - </div> - )) - ) : ( - <div className="flex flex-col items-center justify-center h-full p-6 text-center"> - <Ban className="h-8 w-8 text-gray-500 mb-2" /> - <p className="text-gray-400">No ban entries found</p> - </div> - )} - </div> - </ScrollArea> - </div> - </div> - </CardContent> - </Card> - ); -} -\ No newline at end of file diff --git a/src/components/dashboard/overview-pane.tsx b/src/components/dashboard/overview-pane.tsx @@ -1,151 +0,0 @@ -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../ui/dialog"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" -import { Label } from "../ui/label" -import { Plus, Ban, AlertTriangle, CheckCircle, X } from "lucide-react"; -import { Button } from "../ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; -import { Input } from "../ui/input"; -import { Team } from "@/app/dashboard/team-management"; -import AddProtectedRoom from "./modals/add-protected-room"; - -interface OverviewProps { - selectedTeam: Team; - reports: any[]; - policyLists: any[]; -} - -export default function Overview({ selectedTeam, reports, policyLists }: OverviewProps) { - - return ( - <> - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> - <Card className="border-gray-800 bg-gray-950"> - <CardHeader className="pb-2"> - <CardTitle className="text-sm font-medium">Protected Rooms</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">{selectedTeam.rooms.length}</div> - <div className="flex justify-between items-center"> - <p className="text-xs text-gray-400">Monitored by this bot</p> - <AddProtectedRoom /> - </div> - </CardContent> - </Card> - <Card className="border-gray-800 bg-gray-950"> - <CardHeader className="pb-2"> - <CardTitle className="text-sm font-medium">Active Reports</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">{reports.length}</div> - <p className="text-xs text-gray-400"> - {reports.filter((r) => r.priority === "high").length} high priority - </p> - </CardContent> - </Card> - <Card className="border-gray-800 bg-gray-950"> - <CardHeader className="pb-2"> - <CardTitle className="text-sm font-medium">Bans Issued</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold"> - {policyLists.reduce((total, list) => total + list.entries.length, 0)} - </div> - <p className="text-xs text-gray-400">Across all policy lists</p> - </CardContent> - </Card> - <Card className="border-gray-800 bg-gray-950"> - <CardHeader className="pb-2"> - <CardTitle className="text-sm font-medium">Bot Status</CardTitle> - </CardHeader> - <CardContent> - <div className="flex items-center gap-2"> - <div className="h-3 w-3 rounded-full bg-green-500"></div> - <div className="text-sm font-medium">Online</div> - </div> - <p className="text-xs text-gray-400">Last restart: 7d ago</p> - </CardContent> - </Card> - </div> - - <div className="grid gap-4 md:grid-cols-2"> - <Card className="border-gray-800 bg-gray-950"> - <CardHeader> - <CardTitle>Recent Activity</CardTitle> - <CardDescription className="text-gray-400">Latest moderation actions</CardDescription> - </CardHeader> - <CardContent> - <div className="space-y-4"> - <div className="flex items-start gap-4"> - <div className="rounded-full bg-red-500/20 p-2"> - <Ban className="h-4 w-4 text-red-500" /> - </div> - <div className="space-y-1"> - <p className="text-sm font-medium">User banned</p> - <p className="text-xs text-gray-400">@spammer:matrix.org was banned from all rooms</p> - <p className="text-xs text-gray-500">10 minutes ago</p> - </div> - </div> - <div className="flex items-start gap-4"> - <div className="rounded-full bg-yellow-500/20 p-2"> - <AlertTriangle className="h-4 w-4 text-yellow-500" /> - </div> - <div className="space-y-1"> - <p className="text-sm font-medium">Report created</p> - <p className="text-xs text-gray-400">Spam detected in #support</p> - <p className="text-xs text-gray-500">1 hour ago</p> - </div> - </div> - <div className="flex items-start gap-4"> - <div className="rounded-full bg-green-500/20 p-2"> - <CheckCircle className="h-4 w-4 text-green-500" /> - </div> - <div className="space-y-1"> - <p className="text-sm font-medium">Report resolved</p> - <p className="text-xs text-gray-400">Harassment report in #community resolved</p> - <p className="text-xs text-gray-500">3 hours ago</p> - </div> - </div> - </div> - </CardContent> - </Card> - - <Card className="border-gray-800 bg-gray-950"> - <CardHeader> - <div className="flex items-center justify-between"> - <div> - <CardTitle>Protected Rooms</CardTitle> - <CardDescription className="text-gray-400">Rooms monitored by this bot</CardDescription> - </div> - <AddProtectedRoom filled /> - </div> - </CardHeader> - <CardContent> - <div className="space-y-4"> - {selectedTeam.rooms.map((room, index) => ( - <div key={index} className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <div className="h-2 w-2 rounded-full bg-green-500"></div> - <p className="text-sm">{room}</p> - </div> - <Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-gray-400 hover:text-red-400"> - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </Button> - </div> - ))} - </div> - </CardContent> - </Card> - </div> - </> - ); - -} -\ No newline at end of file diff --git a/src/components/dashboard/reports-pane.tsx b/src/components/dashboard/reports-pane.tsx @@ -1,251 +0,0 @@ -import { - Dialog, - DialogContent, - DialogDescription, - - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../ui/dialog" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" -import { MessageSquare, UserPlus, Badge, CheckCircle, Filter, Ban, AlertTriangle } from "lucide-react" -import { BlurredImage } from "../blurred-image" -import { BlurredText } from "../blurred-text" -import { Button } from "../ui/button" -import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../ui/card" - -interface ReportsProps { - selectedReport: string | null; - reports: any[]; - setSelectedReport: (report: string) => void; -} - -export default function Reports({ selectedReport, reports, setSelectedReport }: ReportsProps) { - return ( - <Card className="border-gray-800 bg-gray-950"> - <CardHeader className="pb-2"> - <div className="flex items-center justify-between"> - <CardTitle>Active Reports</CardTitle> - <div className="flex items-center gap-2"> - <Select defaultValue="all"> - <SelectTrigger className="h-8 w-[120px] bg-gray-900 border-gray-800"> - <SelectValue placeholder="Filter" /> - </SelectTrigger> - <SelectContent className="bg-gray-900 border-gray-800"> - <SelectItem value="all">All Reports</SelectItem> - <SelectItem value="message">Messages</SelectItem> - <SelectItem value="invite">Invites</SelectItem> - <SelectItem value="high">High Priority</SelectItem> - </SelectContent> - </Select> - </div> - </div> - <CardDescription className="text-gray-400">Reports requiring moderation action</CardDescription> - </CardHeader> - <CardContent> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div className="space-y-4"> - <h3 className="text-sm font-medium text-gray-400">Report List</h3> - {reports.length > 0 ? ( - reports.map((report) => ( - <div - key={report.id} - className={`p-3 rounded-md border cursor-pointer transition-colors ${selectedReport === report.id - ? "bg-gray-800 border-purple-600" - : "bg-gray-900 border-gray-800 hover:border-gray-700" - }`} - onClick={() => setSelectedReport(report.id)} - > - <div className="flex items-center justify-between mb-2"> - <div className="flex items-center gap-2"> - {report.type === "message" ? ( - <MessageSquare className="h-4 w-4 text-blue-400" /> - ) : ( - <UserPlus className="h-4 w-4 text-green-400" /> - )} - <span className="text-sm font-medium"> - {report.type === "message" ? "Message Report" : "Invite Report"} - </span> - </div> - <Badge - className={ - report.priority === "high" - ? "bg-red-900/50 text-red-300 hover:bg-red-900/70" - : "bg-yellow-900/50 text-yellow-300 hover:bg-yellow-900/70" - } - > - {report.priority} - </Badge> - </div> - <p className="text-xs text-gray-400 mb-1">{report.room}</p> - <p className="text-xs text-gray-400 mb-1"> - {report.type === "message" - ? `From: ${report.subject.sender}` - : `From: ${report.subject.sender} To: ${report.subject.target}`} - </p> - <p className="text-xs text-gray-500">{new Date(report.timestamp).toLocaleString()}</p> - </div> - )) - ) : ( - <div className="flex flex-col items-center justify-center p-8 text-center"> - <CheckCircle className="h-8 w-8 text-gray-500 mb-2" /> - <p className="text-gray-400">No active reports</p> - </div> - )} - </div> - - <div className="space-y-4"> - <h3 className="text-sm font-medium text-gray-400">Report Details</h3> - {selectedReport ? ( - (() => { - const report = reports.find((r) => r.id === selectedReport) - if (!report) return <p>Select a report to view details</p> - - return ( - <div className="space-y-4"> - <div className="p-4 rounded-md bg-gray-900 border border-gray-800"> - <div className="flex items-center justify-between mb-3"> - <h4 className="font-medium">Report Information</h4> - <Badge className="bg-purple-900/50 text-purple-300">{report.status}</Badge> - </div> - - <div className="space-y-2 text-sm"> - <div className="flex justify-between"> - <span className="text-gray-400">Reported by:</span> - <span>{report.reporter}</span> - </div> - <div className="flex justify-between"> - <span className="text-gray-400">Room:</span> - <span>{report.room}</span> - </div> - <div className="flex justify-between"> - <span className="text-gray-400">Reason:</span> - <span>{report.reason}</span> - </div> - <div className="flex justify-between"> - <span className="text-gray-400">Time:</span> - <span>{new Date(report.timestamp).toLocaleString()}</span> - </div> - </div> - </div> - - <div className="p-4 rounded-md bg-gray-900 border border-gray-800"> - <h4 className="font-medium mb-3"> - {report.type === "message" ? "Message Content" : "Invite Details"} - </h4> - - {report.type === "message" && ( - <div className="space-y-3"> - <div className="flex justify-between text-sm"> - <span className="text-gray-400">Sender:</span> - <span>{report.subject.sender}</span> - </div> - <div className="flex justify-between text-sm"> - <span className="text-gray-400">Message ID:</span> - <span className="font-mono text-xs">{report.subject.id}</span> - </div> - <div className="flex justify-between text-sm"> - <span className="text-gray-400">Time:</span> - <span>{new Date(report.subject.timestamp).toLocaleString()}</span> - </div> - - <div className="mt-4"> - <div className="flex items-center justify-between mb-2"> - <span className="text-sm text-gray-400">Content:</span> - {report.subject.content?.msgtype === "m.text" && ( - <Button variant="ghost" size="sm" className="h-6 px-2 text-gray-400"> - <Filter className="h-3 w-3 mr-1" /> - Filter - </Button> - )} - </div> - - {report.subject.content?.msgtype === "m.text" ? ( - <BlurredText text={report.subject.content?.body} /> - ) : report.subject.content?.msgtype === "m.image" ? ( - <div className="mt-2"> - <BlurredImage - src="/placeholder.svg?height=300&width=400" - alt={report.subject.content?.body} - width={400} - height={300} - /> - <p className="text-xs text-gray-500 mt-1"> - Image: {report.subject.content.body} - </p> - </div> - ) : ( - <p className="text-sm">Unsupported content type</p> - )} - </div> - </div> - )} - - {report.type === "invite" && ( - <div className="space-y-3"> - <div className="flex justify-between text-sm"> - <span className="text-gray-400">Sender:</span> - <span>{report.subject.sender}</span> - </div> - <div className="flex justify-between text-sm"> - <span className="text-gray-400">Target:</span> - <span>{report.subject.target}</span> - </div> - <div className="flex justify-between text-sm"> - <span className="text-gray-400">Time:</span> - <span>{new Date(report.subject.timestamp).toLocaleString()}</span> - </div> - </div> - )} - </div> - - <div className="flex gap-2 mt-4"> - <Button className="bg-green-600 text-white hover:bg-green-700"> - <CheckCircle className="mr-2 h-4 w-4" /> - Resolve - </Button> - <Dialog> - <DialogTrigger asChild> - <Button - variant="outline" - className="border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300" - > - <Ban className="mr-2 h-4 w-4" /> - Ban User - </Button> - </DialogTrigger> - <DialogContent className="bg-gray-950 border-gray-800"> - <DialogHeader> - <DialogTitle>Ban User</DialogTitle> - <DialogDescription className="text-gray-400"> - Ban {report.subject.sender} from your Matrix rooms - </DialogDescription> - </DialogHeader> - {/* Ban dialog content */} - </DialogContent> - </Dialog> - {report.type === "message" && ( - <Button - variant="outline" - className="border-gray-700 text-gray-400 hover:bg-gray-900 hover:text-gray-300" - > - <MessageSquare className="mr-2 h-4 w-4" /> - Delete Message - </Button> - )} - </div> - </div> - ) - })() - ) : ( - <div className="flex flex-col items-center justify-center p-8 text-center"> - <AlertTriangle className="h-8 w-8 text-gray-500 mb-2" /> - <p className="text-gray-400">Select a report to view details</p> - </div> - )} - </div> - </div> - </CardContent> - </Card> - ) -} -\ No newline at end of file diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx @@ -1,66 +0,0 @@ -"use client" - -import * as React from "react" -import { ChevronLeft, ChevronRight } from "lucide-react" -import { DayPicker } from "react-day-picker" - -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" - -export type CalendarProps = React.ComponentProps<typeof DayPicker> - -function Calendar({ - className, - classNames, - showOutsideDays = true, - ...props -}: CalendarProps) { - return ( - <DayPicker - showOutsideDays={showOutsideDays} - className={cn("p-3", className)} - classNames={{ - months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", - month: "space-y-4", - caption: "flex justify-center pt-1 relative items-center", - caption_label: "text-sm font-medium", - nav: "space-x-1 flex items-center", - nav_button: cn( - buttonVariants({ variant: "outline" }), - "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" - ), - nav_button_previous: "absolute left-1", - nav_button_next: "absolute right-1", - table: "w-full border-collapse space-y-1", - head_row: "flex", - head_cell: - "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", - row: "flex w-full mt-2", - cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", - day: cn( - buttonVariants({ variant: "ghost" }), - "h-9 w-9 p-0 font-normal aria-selected:opacity-100" - ), - day_range_end: "day-range-end", - day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", - day_outside: - "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", - day_disabled: "text-muted-foreground opacity-50", - day_range_middle: - "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", - ...classNames, - }} - components={{ - IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />, - IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />, - }} - {...props} - /> - ) -} -Calendar.displayName = "Calendar" - -export { Calendar } diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx @@ -1,365 +0,0 @@ -"use client" - -import * as React from "react" -import * as RechartsPrimitive from "recharts" - -import { cn } from "@/lib/utils" - -// Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: ".dark" } as const - -export type ChartConfig = { - [k in string]: { - label?: React.ReactNode - icon?: React.ComponentType - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record<keyof typeof THEMES, string> } - ) -} - -type ChartContextProps = { - config: ChartConfig -} - -const ChartContext = React.createContext<ChartContextProps | null>(null) - -function useChart() { - const context = React.useContext(ChartContext) - - if (!context) { - throw new Error("useChart must be used within a <ChartContainer />") - } - - return context -} - -const ChartContainer = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - config: ChartConfig - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >["children"] - } ->(({ id, className, children, config, ...props }, ref) => { - const uniqueId = React.useId() - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` - - return ( - <ChartContext.Provider value={{ config }}> - <div - data-chart={chartId} - ref={ref} - className={cn( - "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", - className - )} - {...props} - > - <ChartStyle id={chartId} config={config} /> - <RechartsPrimitive.ResponsiveContainer> - {children} - </RechartsPrimitive.ResponsiveContainer> - </div> - </ChartContext.Provider> - ) -}) -ChartContainer.displayName = "Chart" - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme || config.color - ) - - if (!colorConfig.length) { - return null - } - - return ( - <style - dangerouslySetInnerHTML={{ - __html: Object.entries(THEMES) - .map( - ([theme, prefix]) => ` -${prefix} [data-chart=${id}] { -${colorConfig - .map(([key, itemConfig]) => { - const color = - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || - itemConfig.color - return color ? ` --color-${key}: ${color};` : null - }) - .join("\n")} -} -` - ) - .join("\n"), - }} - /> - ) -} - -const ChartTooltip = RechartsPrimitive.Tooltip - -const ChartTooltipContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<typeof RechartsPrimitive.Tooltip> & - React.ComponentProps<"div"> & { - hideLabel?: boolean - hideIndicator?: boolean - indicator?: "line" | "dot" | "dashed" - nameKey?: string - labelKey?: string - } ->( - ( - { - active, - payload, - className, - indicator = "dot", - hideLabel = false, - hideIndicator = false, - label, - labelFormatter, - labelClassName, - formatter, - color, - nameKey, - labelKey, - }, - ref - ) => { - const { config } = useChart() - - const tooltipLabel = React.useMemo(() => { - if (hideLabel || !payload?.length) { - return null - } - - const [item] = payload - const key = `${labelKey || item.dataKey || item.name || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) - const value = - !labelKey && typeof label === "string" - ? config[label as keyof typeof config]?.label || label - : itemConfig?.label - - if (labelFormatter) { - return ( - <div className={cn("font-medium", labelClassName)}> - {labelFormatter(value, payload)} - </div> - ) - } - - if (!value) { - return null - } - - return <div className={cn("font-medium", labelClassName)}>{value}</div> - }, [ - label, - labelFormatter, - payload, - hideLabel, - labelClassName, - config, - labelKey, - ]) - - if (!active || !payload?.length) { - return null - } - - const nestLabel = payload.length === 1 && indicator !== "dot" - - return ( - <div - ref={ref} - className={cn( - "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", - className - )} - > - {!nestLabel ? tooltipLabel : null} - <div className="grid gap-1.5"> - {payload.map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) - const indicatorColor = color || item.payload.fill || item.color - - return ( - <div - key={item.dataKey} - className={cn( - "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", - indicator === "dot" && "items-center" - )} - > - {formatter && item?.value !== undefined && item.name ? ( - formatter(item.value, item.name, item, index, item.payload) - ) : ( - <> - {itemConfig?.icon ? ( - <itemConfig.icon /> - ) : ( - !hideIndicator && ( - <div - className={cn( - "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", - { - "h-2.5 w-2.5": indicator === "dot", - "w-1": indicator === "line", - "w-0 border-[1.5px] border-dashed bg-transparent": - indicator === "dashed", - "my-0.5": nestLabel && indicator === "dashed", - } - )} - style={ - { - "--color-bg": indicatorColor, - "--color-border": indicatorColor, - } as React.CSSProperties - } - /> - ) - )} - <div - className={cn( - "flex flex-1 justify-between leading-none", - nestLabel ? "items-end" : "items-center" - )} - > - <div className="grid gap-1.5"> - {nestLabel ? tooltipLabel : null} - <span className="text-muted-foreground"> - {itemConfig?.label || item.name} - </span> - </div> - {item.value && ( - <span className="font-mono font-medium tabular-nums text-foreground"> - {item.value.toLocaleString()} - </span> - )} - </div> - </> - )} - </div> - ) - })} - </div> - </div> - ) - } -) -ChartTooltipContent.displayName = "ChartTooltip" - -const ChartLegend = RechartsPrimitive.Legend - -const ChartLegendContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & - Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { - hideIcon?: boolean - nameKey?: string - } ->( - ( - { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, - ref - ) => { - const { config } = useChart() - - if (!payload?.length) { - return null - } - - return ( - <div - ref={ref} - className={cn( - "flex items-center justify-center gap-4", - verticalAlign === "top" ? "pb-3" : "pt-3", - className - )} - > - {payload.map((item) => { - const key = `${nameKey || item.dataKey || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) - - return ( - <div - key={item.value} - className={cn( - "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" - )} - > - {itemConfig?.icon && !hideIcon ? ( - <itemConfig.icon /> - ) : ( - <div - className="h-2 w-2 shrink-0 rounded-[2px]" - style={{ - backgroundColor: item.color, - }} - /> - )} - {itemConfig?.label} - </div> - ) - })} - </div> - ) - } -) -ChartLegendContent.displayName = "ChartLegend" - -// Helper to extract item config from a payload. -function getPayloadConfigFromPayload( - config: ChartConfig, - payload: unknown, - key: string -) { - if (typeof payload !== "object" || payload === null) { - return undefined - } - - const payloadPayload = - "payload" in payload && - typeof payload.payload === "object" && - payload.payload !== null - ? payload.payload - : undefined - - let configLabelKey: string = key - - if ( - key in payload && - typeof payload[key as keyof typeof payload] === "string" - ) { - configLabelKey = payload[key as keyof typeof payload] as string - } else if ( - payloadPayload && - key in payloadPayload && - typeof payloadPayload[key as keyof typeof payloadPayload] === "string" - ) { - configLabelKey = payloadPayload[ - key as keyof typeof payloadPayload - ] as string - } - - return configLabelKey in config - ? config[configLabelKey] - : config[key as keyof typeof config] -} - -export { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, - ChartStyle, -} diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts @@ -18,6 +18,7 @@ type ToasterToast = ToastProps & { action?: ToastActionElement } +// eslint-disable-next-line @typescript-eslint/no-unused-vars const actionTypes = { ADD_TOAST: "ADD_TOAST", UPDATE_TOAST: "UPDATE_TOAST", @@ -36,21 +37,21 @@ type ActionType = typeof actionTypes type Action = | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast - } + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } | { - type: ActionType["UPDATE_TOAST"] - toast: Partial<ToasterToast> - } + type: ActionType["UPDATE_TOAST"] + toast: Partial<ToasterToast> + } | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] - } + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } interface State { toasts: ToasterToast[] @@ -108,9 +109,9 @@ export const reducer = (state: State, action: Action): State => { toasts: state.toasts.map((t) => t.id === toastId || toastId === undefined ? { - ...t, - open: false, - } + ...t, + open: false, + } : t ), } @@ -158,7 +159,7 @@ function toast({ ...props }: Toast) { ...props, id, open: true, - onOpenChange: (open) => { + onOpenChange: (open: boolean) => { if (!open) dismiss() }, }, diff --git a/src/contexts/session-context.tsx b/src/contexts/session-context.tsx @@ -0,0 +1,130 @@ +"use client" + +import { createContext, useContext, useEffect, useState, type ReactNode } from "react" +import { useRouter } from "next/navigation" + +type User = { + id: string + matrixId: string + displayName: string + avatarUrl?: string + isAdmin: boolean +} + +type SessionContextType = { + user: User | null + isLoading: boolean + isAuthenticated: boolean + login: (matrixId: string) => Promise<void> + register: (matrixId: string) => Promise<void> + logout: () => Promise<void> +} + +const SessionContext = createContext<SessionContextType | undefined>(undefined) + +export function SessionProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState<User | null>(null) + const [isLoading, setIsLoading] = useState(true) + const router = useRouter() + + // Check for existing session on mount + useEffect(() => { + const checkSession = async () => { + try { + const response = await fetch("/api/auth/session") + const data = await response.json() + + if (data.authenticated && data.user) { + setUser(data.user) + } + } catch (error) { + console.error("Failed to check session:", error) + } finally { + setIsLoading(false) + } + } + + checkSession() + }, []) + + const login = async (matrixId: string) => { + setIsLoading(true) + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ matrixId }), + }) + + if (!response.ok) { + throw new Error("Authentication failed") + } + + const data = await response.json() + setUser(data.user) + router.push("/dashboard") + } catch (error) { + console.error("Login failed:", error) + throw error + } finally { + setIsLoading(false) + } + } + + const register = async (matrixId: string) => { + setIsLoading(true) + try { + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ matrixId }), + }) + + if (!response.ok) { + throw new Error("Registration failed") + } + + const data = await response.json() + setUser(data.user) + router.push("/dashboard") + } catch (error) { + console.error("Registration failed:", error) + throw error + } finally { + setIsLoading(false) + } + } + + const logout = async () => { + try { + await fetch("/api/auth/logout", { method: "POST" }) + setUser(null) + router.push("/") + } catch (error) { + console.error("Logout failed:", error) + } + } + + return ( + <SessionContext.Provider + value={{ + user, + isLoading, + isAuthenticated: !!user, + login, + register, + logout, + }} + > + {children} + </SessionContext.Provider> + ) +} + +export function useSession() { + const context = useContext(SessionContext) + if (context === undefined) { + throw new Error("useSession must be used within a SessionProvider") + } + return context +} diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx @@ -1,19 +0,0 @@ -import * as React from "react" - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener("change", onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) - }, []) - - return !!isMobile -} diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts @@ -1,194 +0,0 @@ -"use client" - -// Inspired by react-hot-toast library -import * as React from "react" - -import type { - ToastActionElement, - ToastProps, -} from "@/components/ui/toast" - -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 - -type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - -const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const - -let count = 0 - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() -} - -type ActionType = typeof actionTypes - -type Action = - | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast - } - | { - type: ActionType["UPDATE_TOAST"] - toast: Partial<ToasterToast> - } - | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] - } - | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } - -interface State { - toasts: ToasterToast[] -} - -const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } - - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t - ), - } - - case "DISMISS_TOAST": { - const { toastId } = action - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId) - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t - ), - } - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - } - } -} - -const listeners: Array<(state: State) => void> = [] - -let memoryState: State = { toasts: [] } - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) -} - -type Toast = Omit<ToasterToast, "id"> - -function toast({ ...props }: Toast) { - const id = genId() - - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) - - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - }, - }, - }) - - return { - id: id, - dismiss, - update, - } -} - -function useToast() { - const [state, setState] = React.useState<State>(memoryState) - - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } -} - -export { useToast, toast } diff --git a/src/lib/auth.ts b/src/lib/auth.ts @@ -0,0 +1,82 @@ +import { cookies } from "next/headers" +import type { NextRequest, NextResponse } from "next/server" +import { jwtVerify, SignJWT } from "jose" + +// Secret key for JWT signing - in production, use a proper secret management system +const JWT_SECRET = new TextEncoder().encode( + process.env.JWT_SECRET || "this_is_a_secret_key_that_should_be_in_env_variables", +) + +export type User = { + id: string + matrixId: string + displayName: string + avatarUrl?: string + isAdmin: boolean +} + +export async function createSession(user: User): Promise<string> { + // Create a JWT token with user data + const token = await new SignJWT({ user }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("7d") // Token expires in 7 days + .sign(JWT_SECRET) + + return token +} + +export async function setSessionCookie(token: string, response: NextResponse): Promise<void> { + // Set the session cookie with secure attributes + response.cookies.set({ + name: "matrix_session", + value: token, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 days + path: "/", + }) +} + +export async function getSessionUser(request?: NextRequest): Promise<User | null> { + try { + // Get the session cookie + const cookieStore = request ? request.cookies : await cookies() + const sessionCookie = cookieStore.get("matrix_session") + + if (!sessionCookie?.value) { + return null + } + + // Verify the JWT token + try { + const { payload } = await jwtVerify(sessionCookie.value, JWT_SECRET) + + if (!payload.user) { + return null + } + + return payload.user as User + } catch (jwtError) { + console.error("JWT verification failed:", jwtError) + return null + } + } catch (error) { + console.error("Failed to verify session:", error) + return null + } +} + +export async function clearSession(response: NextResponse): Promise<void> { + // Clear the session cookie + response.cookies.set({ + name: "matrix_session", + value: "", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + path: "/", + }) +} diff --git a/src/middleware.ts b/src/middleware.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import { getSessionUser } from "./lib/auth" + +export async function middleware(request: NextRequest) { + // Get the pathname of the request + const path = request.nextUrl.pathname + + // Define public paths that don't require authentication + const isPublicPath = path === "/" || path === "/login" || path === "/register" + + // Check if the user is authenticated + const user = await getSessionUser(request) + const isAuthenticated = !!user + + // If the path is dashboard and the user is not authenticated, redirect to login + if (path.startsWith("/dashboard") && !isAuthenticated) { + const url = new URL("/login", request.url) + return NextResponse.redirect(url) + } + + // If the user is authenticated and trying to access login/register, redirect to dashboard + if (isAuthenticated && isPublicPath) { + const url = new URL("/dashboard", request.url) + return NextResponse.redirect(url) + } + + return NextResponse.next() +} + +// Configure the middleware to run only on specific paths +export const config = { + matcher: ["/", "/login", "/register", "/dashboard/:path*"], +} diff --git a/tailwind.config.ts b/tailwind.config.ts @@ -19,6 +19,10 @@ const config = { }, }, extend: { + fontFamily: { + sans: ['var(--font-inter)'], + mono: ['var(--font-roboto-mono)'], + }, colors: { border: "hsl(var(--border))", input: "hsl(var(--input))",