diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f650315 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index c5ee0da..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Steve Kabore - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/app/admin/investors/page.tsx b/app/admin/investors/page.tsx new file mode 100644 index 0000000..4c5f98e --- /dev/null +++ b/app/admin/investors/page.tsx @@ -0,0 +1,176 @@ +import { Suspense } from "react" +import { redirect } from "next/navigation" +import Link from "next/link" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { isAdmin } from "@/lib/auth" +import { createServerSupabaseClient } from "@/lib/supabase/server" +import AdminLayout from "@/components/admin/admin-layout" + +// Force dynamic rendering for this page +export const dynamic = "force-dynamic" + +async function InvestorsContent() { + try { + // Check if user is admin + const admin = await isAdmin() + if (!admin) { + redirect("/login") + } + + const supabase = createServerSupabaseClient() + + // Fetch investors with error handling + let investors = [] + try { + const { data } = await supabase.from("investors").select("*").order("created_at", { ascending: false }) + investors = data || [] + } catch (error) { + console.error("Error fetching investors:", error) + } + + return ( +
+
+
+

Investors

+

Manage investor information and track investment status

+
+
+ + + +
+
+ + + + Investor List + All investors who have expressed interest in SonicReactor + + +
+ + + + + + + + + + + + + + {investors?.map((investor) => ( + + + + + + + + + + ))} + {(!investors || investors.length === 0) && ( + + + + )} + +
NameEmailPhoneAmountStatusDateActions
{investor.name}{investor.email}{investor.phone || "N/A"}${investor.amount.toLocaleString()} + + {investor.status.charAt(0).toUpperCase() + investor.status.slice(1)} + + {new Date(investor.created_at).toLocaleDateString()} + + + +
+ No investors found +
+
+
+
+ + + + Investment Summary + Overview of investment interest by status + + +
+
+
Pending
+
+ $ + {investors + ?.filter((i) => i.status === "pending") + .reduce((sum, i) => sum + i.amount, 0) + .toLocaleString() || "0"} +
+
+ {investors?.filter((i) => i.status === "pending").length || 0} investors +
+
+ +
+
Approved
+
+ $ + {investors + ?.filter((i) => i.status === "approved") + .reduce((sum, i) => sum + i.amount, 0) + .toLocaleString() || "0"} +
+
+ {investors?.filter((i) => i.status === "approved").length || 0} investors +
+
+ +
+
Rejected
+
+ $ + {investors + ?.filter((i) => i.status === "rejected") + .reduce((sum, i) => sum + i.amount, 0) + .toLocaleString() || "0"} +
+
+ {investors?.filter((i) => i.status === "rejected").length || 0} investors +
+
+
+
+
+
+ ) + } catch (error) { + console.error("Error in InvestorsContent:", error) + return
An error occurred while loading investors.
+ } +} + +export default function InvestorsPage() { + return ( + + Loading investors...}> + + + + ) +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..ccd8f7c --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,33 @@ +import type React from "react" +import { Suspense } from "react" +import { redirect } from "next/navigation" +import { getCurrentUser, isAdmin } from "@/lib/auth" +import { DashboardHeader } from "@/components/layout/dashboard-header" + +async function AdminLayoutContent({ children }: { children: React.ReactNode }) { + // Check if user is authenticated and is admin + const user = await getCurrentUser() + if (!user) { + redirect("/login") + } + + const userIsAdmin = await isAdmin() + if (!userIsAdmin) { + redirect("/dashboard") + } + + return ( +
+ +
{children}
+
+ ) +} + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( + Loading admin panel...}> + {children} + + ) +} diff --git a/app/admin/overview/page.tsx b/app/admin/overview/page.tsx new file mode 100644 index 0000000..5fa8852 --- /dev/null +++ b/app/admin/overview/page.tsx @@ -0,0 +1,88 @@ +// Force dynamic rendering for this page +export const dynamic = "force-dynamic" + +import type { Metadata } from "next" +import { DashboardStats } from "@/components/admin/dashboard-stats" +import { RecentActivity } from "@/components/admin/recent-activity" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { ArrowRight, Users, AudioWaveform as Waveform, Cpu, Settings } from "lucide-react" +import Link from "next/link" + +export const metadata: Metadata = { + title: "Admin Overview | SonicReactor", + description: "System overview and statistics for SonicReactor administrators", +} + +export default function AdminOverviewPage() { + return ( +
+
+
+

System Overview

+

Monitor system performance and recent activity

+
+
+ +
+ + +
+ + +
+ + + Quick Actions + Common administrative tasks + + +
+ + + + +

Manage Investors

+

View and manage investor accounts

+
+
+ + + + + + +

Sound Patterns

+

Manage sound pattern library

+
+
+ + + + + + +

Device Management

+

Monitor and configure devices

+
+
+ +
+ +
+ + + +
+
+
+
+
+
+
+ ) +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..b5237f7 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,249 @@ +import { Suspense } from "react" +import { redirect } from "next/navigation" +import Link from "next/link" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { LineChart, PieChart } from "@/components/ui/charts" +import { isAdmin } from "@/lib/auth" +import { createServerSupabaseClient } from "@/lib/supabase/server" +import AdminLayout from "@/components/admin/admin-layout" + +// Force dynamic rendering for this page +export const dynamic = "force-dynamic" + +async function AdminDashboardContent() { + try { + // Check if user is admin + const admin = await isAdmin() + if (!admin) { + redirect("/login") + } + + const supabase = createServerSupabaseClient() + + // Fetch dashboard data with error handling + const [investorsResult, patternsResult, devicesResult, detectionsResult] = await Promise.allSettled([ + supabase.from("investors").select("*", { count: "exact", head: true }), + supabase.from("sound_patterns").select("*", { count: "exact", head: true }), + supabase.from("devices").select("*", { count: "exact", head: true }), + supabase.from("detection_history").select("*", { count: "exact", head: true }), + ]) + + const investorsCount = investorsResult.status === "fulfilled" ? investorsResult.value.count : 0 + const patternsCount = patternsResult.status === "fulfilled" ? patternsResult.value.count : 0 + const devicesCount = devicesResult.status === "fulfilled" ? devicesResult.value.count : 0 + const detectionsCount = detectionsResult.status === "fulfilled" ? detectionsResult.value.count : 0 + + // Fetch recent detections with error handling + let recentDetections = [] + try { + const { data } = await supabase + .from("detection_history") + .select(` + id, + confidence, + action_executed, + detected_at, + sound_patterns (label), + devices (name) + `) + .order("detected_at", { ascending: false }) + .limit(5) + + recentDetections = data || [] + } catch (error) { + console.error("Error fetching recent detections:", error) + } + + return ( +
+
+
+

Admin Dashboard

+

Overview of your SonicReactor system and analytics

+
+
+ + + +
+
+ +
+ + + Total Investors + + + + + + + +
{investorsCount}
+

+10% from last month

+
+
+ + + Sound Patterns + + + + + +
{patternsCount}
+

+12 new patterns this week

+
+
+ + + Connected Devices + + + + + + +
{devicesCount}
+

+3 devices since last week

+
+
+ + + Pattern Detections + + + + + +
{detectionsCount}
+

+24% from last month

+
+
+
+ +
+ + + Detection Analytics + Pattern detection frequency over the past 30 days + + +
+ +
+
+
+ + + Pattern Distribution + Breakdown of detected sound patterns + + +
+ +
+
+
+
+ + + + Recent Detections + Latest sound pattern detections across your devices + + +
+ + + + + + + + + + + + {recentDetections.map((detection) => ( + + + + + + + + ))} + {recentDetections.length === 0 && ( + + + + )} + +
PatternDeviceConfidenceAction ExecutedDetected At
{detection.sound_patterns?.label || "Unknown"}{detection.devices?.name || "Unknown"} + {detection.confidence ? `${(detection.confidence * 100).toFixed(1)}%` : "N/A"} + {detection.action_executed ? "Yes" : "No"}{new Date(detection.detected_at).toLocaleString()}
+ No recent detections found +
+
+
+
+
+ ) + } catch (error) { + console.error("Error in AdminDashboardContent:", error) + return ( +
+

Error Loading Dashboard

+

Please try again later.

+
+ ) + } +} + +export default function AdminDashboard() { + return ( + + Loading dashboard...}> + + + + ) +} diff --git a/app/admin/patterns/new/page.tsx b/app/admin/patterns/new/page.tsx new file mode 100644 index 0000000..e765af8 --- /dev/null +++ b/app/admin/patterns/new/page.tsx @@ -0,0 +1,555 @@ +"use client" + +import type React from "react" + +import { useState, useRef } from "react" +import { useRouter } from "next/navigation" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Switch } from "@/components/ui/switch" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Mic, Upload, Save, Play, Square } from "lucide-react" +import { supabase } from "@/lib/supabase/client" +import AdminLayout from "@/components/admin/admin-layout" + +export default function NewPatternPage() { + const router = useRouter() + const [activeTab, setActiveTab] = useState("record") + const [label, setLabel] = useState("") + const [description, setDescription] = useState("") + const [processCode, setProcessCode] = useState("") + const [isActive, setIsActive] = useState(true) + const [isRecording, setIsRecording] = useState(false) + const [isPlaying, setIsPlaying] = useState(false) + const [audioBlob, setAudioBlob] = useState(null) + const [audioUrl, setAudioUrl] = useState(null) + const [uploadedFile, setUploadedFile] = useState(null) + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [recordingDuration, setRecordingDuration] = useState(0) + + const canvasRef = useRef(null) + const mediaRecorderRef = useRef(null) + const audioContextRef = useRef(null) + const analyserRef = useRef(null) + const dataArrayRef = useRef(null) + const audioChunksRef = useRef([]) + const recordingTimerRef = useRef(null) + const audioElementRef = useRef(null) + const fileInputRef = useRef(null) + + // Initialize audio element for playback + if (typeof window !== "undefined" && !audioElementRef.current) { + audioElementRef.current = new Audio() + } + + const startRecording = async () => { + try { + // Reset previous recording data + audioChunksRef.current = [] + setAudioBlob(null) + setAudioUrl(null) + setRecordingDuration(0) + + // Check if MediaDevices API is supported + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + throw new Error("Media devices API not supported in this browser") + } + + // Try to get the audio stream + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + + // Set up audio context and analyser + audioContextRef.current = new AudioContext() + analyserRef.current = audioContextRef.current.createAnalyser() + analyserRef.current.fftSize = 256 + + const source = audioContextRef.current.createMediaStreamSource(stream) + source.connect(analyserRef.current) + + const bufferLength = analyserRef.current.frequencyBinCount + dataArrayRef.current = new Uint8Array(bufferLength) + + // Set up media recorder with audio/webm MIME type + mediaRecorderRef.current = new MediaRecorder(stream, { + mimeType: "audio/webm;codecs=opus", + }) + + // Set up event handlers for the media recorder + mediaRecorderRef.current.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data) + } + } + + mediaRecorderRef.current.onstop = () => { + // Create a blob from all the chunks + const audioBlob = new Blob(audioChunksRef.current, { type: "audio/webm" }) + setAudioBlob(audioBlob) + + // Create a URL for the blob + const audioUrl = URL.createObjectURL(audioBlob) + setAudioUrl(audioUrl) + + // Set up the audio element for playback + if (audioElementRef.current) { + audioElementRef.current.src = audioUrl + } + } + + // Start recording + mediaRecorderRef.current.start(100) // Collect data every 100ms + + setIsRecording(true) + + // Start visualizing + visualize() + + // Set up a timer to track recording duration + let duration = 0 + recordingTimerRef.current = setInterval(() => { + duration += 0.1 + setRecordingDuration(duration) + }, 100) + } catch (error) { + console.error("Error accessing microphone:", error) + setError(error instanceof Error ? error.message : "Failed to access microphone") + } + } + + const stopRecording = () => { + if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") { + mediaRecorderRef.current.stop() + + // Stop all tracks in the stream + mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop()) + + setIsRecording(false) + + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current) + } + } + } + + const playAudio = () => { + if (audioElementRef.current && (audioUrl || uploadedFile)) { + audioElementRef.current.play() + setIsPlaying(true) + + audioElementRef.current.onended = () => { + setIsPlaying(false) + } + } + } + + const stopAudio = () => { + if (audioElementRef.current) { + audioElementRef.current.pause() + audioElementRef.current.currentTime = 0 + setIsPlaying(false) + } + } + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + // Check if file is an audio file + if (!file.type.startsWith("audio/")) { + setError("Please upload an audio file") + return + } + + setUploadedFile(file) + + // Create a URL for the file + const fileUrl = URL.createObjectURL(file) + setAudioUrl(fileUrl) + + // Set up the audio element for playback + if (audioElementRef.current) { + audioElementRef.current.src = fileUrl + + // Get duration when metadata is loaded + audioElementRef.current.onloadedmetadata = () => { + if (audioElementRef.current) { + setRecordingDuration(audioElementRef.current.duration) + } + } + } + + // Clear any previous recording + setAudioBlob(null) + } + } + + const triggerFileInput = () => { + fileInputRef.current?.click() + } + + const visualize = () => { + if (!canvasRef.current || !analyserRef.current || !dataArrayRef.current) return + + const canvas = canvasRef.current + const canvasCtx = canvas.getContext("2d") + if (!canvasCtx) return + + const width = canvas.width + const height = canvas.height + + const draw = () => { + requestAnimationFrame(draw) + + analyserRef.current!.getByteTimeDomainData(dataArrayRef.current!) + + canvasCtx.fillStyle = "rgb(240, 240, 240)" + canvasCtx.fillRect(0, 0, width, height) + + canvasCtx.lineWidth = 2 + canvasCtx.strokeStyle = "rgb(0, 125, 255)" + canvasCtx.beginPath() + + const sliceWidth = width / dataArrayRef.current!.length + let x = 0 + + for (let i = 0; i < dataArrayRef.current!.length; i++) { + const v = dataArrayRef.current![i] / 128.0 + const y = (v * height) / 2 + + if (i === 0) { + canvasCtx.moveTo(x, y) + } else { + canvasCtx.lineTo(x, y) + } + + x += sliceWidth + } + + canvasCtx.lineTo(width, height / 2) + canvasCtx.stroke() + } + + draw() + } + + const renderStaticWaveform = () => { + if (!canvasRef.current) return + + const canvas = canvasRef.current + const canvasCtx = canvas.getContext("2d") + if (!canvasCtx) return + + const width = canvas.width + const height = canvas.height + + // Clear the canvas + canvasCtx.fillStyle = "rgb(240, 240, 240)" + canvasCtx.fillRect(0, 0, width, height) + + // Draw a flat line + canvasCtx.lineWidth = 2 + canvasCtx.strokeStyle = "rgb(180, 180, 180)" + canvasCtx.beginPath() + canvasCtx.moveTo(0, height / 2) + canvasCtx.lineTo(width, height / 2) + canvasCtx.stroke() + + // Add text + canvasCtx.font = "14px Arial" + canvasCtx.fillStyle = "rgb(100, 100, 100)" + canvasCtx.textAlign = "center" + + if (activeTab === "record") { + canvasCtx.fillText('Click "Start Recording" to record a sound pattern', width / 2, height / 2 - 20) + } else { + canvasCtx.fillText("Upload an audio file to visualize the waveform", width / 2, height / 2 - 20) + } + } + + // Initialize the canvas with static visualization + if (typeof window !== "undefined" && canvasRef.current) { + renderStaticWaveform() + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + if (!label) { + setError("Pattern label is required") + return + } + + if (!audioBlob && !uploadedFile) { + setError("Please record or upload an audio file") + return + } + + setIsSubmitting(true) + + try { + // Get current user + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + throw new Error("You must be logged in to create a pattern") + } + + // Upload the audio file to storage + const audioFile = audioBlob || uploadedFile + const fileExt = audioBlob ? "webm" : uploadedFile?.name.split(".").pop() || "wav" + const filePath = `patterns/${user.id}/${Date.now()}.${fileExt}` + + const { data: uploadData, error: uploadError } = await supabase.storage.from("audio").upload(filePath, audioFile!) + + if (uploadError) { + throw uploadError + } + + // Get the public URL for the uploaded file + const { + data: { publicUrl }, + } = supabase.storage.from("audio").getPublicUrl(filePath) + + // Create the sound pattern record + const { data: pattern, error: patternError } = await supabase.from("sound_patterns").insert({ + label, + description, + process_code: processCode || null, + audio_url: publicUrl, + duration: recordingDuration, + is_active: isActive, + created_by: user.id, + }) + + if (patternError) { + throw patternError + } + + // Generate a waveform image (in a real implementation) + // This would call a server function to generate a waveform image + + // Success! Redirect to the patterns list + router.push("/admin/patterns") + } catch (error) { + console.error("Error creating pattern:", error) + setError(error instanceof Error ? error.message : "Failed to create pattern") + setIsSubmitting(false) + } + } + + return ( + +
+
+

Add New Sound Pattern

+

Record or upload a sound pattern for detection

+
+ +
+
+ + + Pattern Information + Enter details about the sound pattern + + +
+ + setLabel(e.target.value)} + required + /> +
+ +
+ +