initial commit
This commit is contained in:
88
components/BlockNoteEditor/BlockNoteEditor.tsx
Normal file
88
components/BlockNoteEditor/BlockNoteEditor.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
'use client'
|
||||
import '@blocknote/core/fonts/inter.css'
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
|
||||
import '@blocknote/mantine/style.css'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import { Block, BlockNoteEditor, PartialBlock } from '@blocknote/core'
|
||||
import './styles.css'
|
||||
|
||||
interface BlockNoteEditorLocalProps {
|
||||
onDataChange: (blocks: Block[], html: string) => void
|
||||
initialContent?: PartialBlock[] | undefined
|
||||
onImageUpload?: (imageUrl: string) => void
|
||||
onImageDelete?: (imageUrl: string) => void
|
||||
}
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
|
||||
const response = await fetch('/api/topic-content-image', {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Upload failed')
|
||||
}
|
||||
return result.data.url
|
||||
}
|
||||
|
||||
export default function BlockNoteEditorLocal({
|
||||
onDataChange,
|
||||
initialContent,
|
||||
onImageUpload,
|
||||
onImageDelete,
|
||||
}: BlockNoteEditorLocalProps) {
|
||||
let editor: BlockNoteEditor
|
||||
try {
|
||||
editor = useCreateBlockNote({
|
||||
initialContent: initialContent,
|
||||
uploadFile: async (file: File) => {
|
||||
const url = await uploadFile(file)
|
||||
if (onImageUpload) {
|
||||
onImageUpload(url)
|
||||
}
|
||||
return url
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
editor = useCreateBlockNote({
|
||||
initialContent: undefined,
|
||||
uploadFile: async (file: File) => {
|
||||
const url = await uploadFile(file)
|
||||
if (onImageUpload) {
|
||||
onImageUpload(url)
|
||||
}
|
||||
return url
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleOnChange = async () => {
|
||||
const blocks = editor.document
|
||||
const html = await editor.blocksToFullHTML(blocks)
|
||||
|
||||
// Check for deleted images
|
||||
if (onImageDelete && initialContent) {
|
||||
const currentImages = blocks
|
||||
.filter((block) => block.type === 'image')
|
||||
.map((block) => block.props.url)
|
||||
|
||||
const initialImages = initialContent
|
||||
.filter((block) => block.type === 'image')
|
||||
.map((block) => block.props.url)
|
||||
|
||||
// Find images that were in initialContent but not in current content
|
||||
const deletedImages = initialImages.filter((url) => !currentImages.includes(url))
|
||||
deletedImages.forEach((url) => onImageDelete(url))
|
||||
}
|
||||
|
||||
onDataChange(blocks, html)
|
||||
}
|
||||
|
||||
return <BlockNoteView onChange={handleOnChange} editor={editor} className={'block-note-editor'} />
|
||||
}
|
||||
1
components/BlockNoteEditor/index.ts
Normal file
1
components/BlockNoteEditor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './BlockNoteEditor'
|
||||
5
components/BlockNoteEditor/styles.css
Normal file
5
components/BlockNoteEditor/styles.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.block-note-editor {
|
||||
.bn-editor {
|
||||
min-height: 30vh;
|
||||
}
|
||||
}
|
||||
81
components/Features.tsx
Normal file
81
components/Features.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Shield, Clock, Headphones, Award, Globe, Lock, Zap, Users } from 'lucide-react'
|
||||
|
||||
const Features = () => {
|
||||
const features = [
|
||||
{
|
||||
icon: Shield,
|
||||
title: '99.9% Uptime SLA',
|
||||
description: 'Industry-leading uptime guarantee with compensation for outages',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: '24/7 Support',
|
||||
description: 'Round-the-clock technical support from hosting experts',
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
title: 'Enterprise Security',
|
||||
description: 'DDoS protection, SSL certificates, and security monitoring',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Lightning Fast',
|
||||
description: 'SSD storage, CDN, and optimized server configurations',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: 'Global Infrastructure',
|
||||
description: 'Data centers worldwide for optimal performance',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Developer Friendly',
|
||||
description: 'APIs, documentation, and tools built for developers',
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
title: 'Industry Recognition',
|
||||
description: 'Trusted by 50,000+ developers and businesses worldwide',
|
||||
},
|
||||
{
|
||||
icon: Headphones,
|
||||
title: 'Expert Consultation',
|
||||
description: 'Human developer consulting for complex projects',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-background">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4">Why Choose SiliconPin?</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Built by developers, for developers. We understand what you need to succeed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{features.map((feature, index) => {
|
||||
const Icon = feature.icon
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="text-center group hover:scale-105 transition-transform duration-300"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-primary rounded-xl flex items-center justify-center mx-auto mb-6 group-hover:shadow-glow transition-shadow duration-300">
|
||||
<Icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-3">{feature.title}</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Features
|
||||
94
components/Hero.tsx
Normal file
94
components/Hero.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowRight, Play, Shield, Zap, Globe } from 'lucide-react'
|
||||
|
||||
const Hero = () => {
|
||||
return (
|
||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="w-full h-full bg-gradient-to-br from-primary/20 via-secondary/10 to-primary/20" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/90 via-secondary/80 to-primary/90" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 container mx-auto px-4 py-20">
|
||||
<div className="max-w-4xl mx-auto text-center text-white">
|
||||
{/* Trust Badges */}
|
||||
<div className="flex items-center justify-center space-x-6 mb-8 text-sm opacity-90">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>99.9% Uptime</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>SSD Storage</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>Global CDN</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Heading */}
|
||||
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight text-balance">
|
||||
Professional
|
||||
<span className="block bg-gradient-to-r from-white to-primary-glow bg-clip-text text-transparent">
|
||||
Hosting Solutions
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="text-xl md:text-2xl mb-8 text-white/90 max-w-3xl mx-auto leading-relaxed text-balance font-medium">
|
||||
VPS, Kubernetes, Developer Services, and Web Tools. Everything you need to build,
|
||||
deploy, and scale your applications with confidence.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6 mb-12">
|
||||
<Button variant="glass" size="xl" className="group">
|
||||
Start Free Trial
|
||||
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xl"
|
||||
className="bg-white/10 border-white/30 text-white hover:bg-white/20"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
Watch Demo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 pt-8 border-t border-white/20">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold mb-1">50k+</div>
|
||||
<div className="text-sm opacity-80">Active Users</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold mb-1">99.9%</div>
|
||||
<div className="text-sm opacity-80">Uptime SLA</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold mb-1">24/7</div>
|
||||
<div className="text-sm opacity-80">Support</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold mb-1">5+ Years</div>
|
||||
<div className="text-sm opacity-80">Experience</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 text-white/60">
|
||||
<div className="animate-bounce">
|
||||
<ArrowRight className="w-6 h-6 rotate-90" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Hero
|
||||
19
components/Logo.tsx
Normal file
19
components/Logo.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
// components/Logo.tsx
|
||||
export function Logo({ size = 32 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 150 150"
|
||||
fill="none"
|
||||
className="logo-svg" // Add a class for easier identification
|
||||
>
|
||||
<rect width="50" height="50" fill="#FF0000" />
|
||||
<rect y="100" width="50" height="50" fill="#0000FF" />
|
||||
<rect x="100" width="50" height="50" fill="#FF0000" />
|
||||
<rect x="100" y="100" width="50" height="50" fill="#0000FF" />
|
||||
<rect x="50" y="50" width="50" height="50" fill="#008000" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
137
components/PWAInstallPrompt.tsx
Normal file
137
components/PWAInstallPrompt.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Download } from 'lucide-react'
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
readonly platforms: string[]
|
||||
readonly userChoice: Promise<{
|
||||
outcome: 'accepted' | 'dismissed'
|
||||
platform: string
|
||||
}>
|
||||
prompt(): Promise<void>
|
||||
}
|
||||
|
||||
export default function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
||||
const [showPrompt, setShowPrompt] = useState(false)
|
||||
const [isIOS, setIsIOS] = useState(false)
|
||||
const [isStandalone, setIsStandalone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if running on iOS
|
||||
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
setIsIOS(iOS)
|
||||
|
||||
// Check if already installed (standalone mode)
|
||||
const standalone =
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true
|
||||
setIsStandalone(standalone)
|
||||
|
||||
// Don't show prompt if already installed
|
||||
if (standalone) return
|
||||
|
||||
// Handle beforeinstallprompt event (Android/Chrome)
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault()
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent)
|
||||
|
||||
// Show prompt after a short delay to avoid being too aggressive
|
||||
setTimeout(() => {
|
||||
setShowPrompt(true)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// Handle app installed event
|
||||
const handleAppInstalled = () => {
|
||||
setShowPrompt(false)
|
||||
setDeferredPrompt(null)
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.addEventListener('appinstalled', handleAppInstalled)
|
||||
|
||||
// For iOS, show prompt after delay if not already installed
|
||||
if (iOS && !standalone) {
|
||||
setTimeout(() => {
|
||||
setShowPrompt(true)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInstallClick = async () => {
|
||||
if (deferredPrompt) {
|
||||
// Android/Chrome install
|
||||
deferredPrompt.prompt()
|
||||
const { outcome } = await deferredPrompt.userChoice
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
setDeferredPrompt(null)
|
||||
setShowPrompt(false)
|
||||
}
|
||||
}
|
||||
// For iOS, the prompt just shows instructions
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false)
|
||||
// Don't show again for this session
|
||||
sessionStorage.setItem('pwa-prompt-dismissed', 'true')
|
||||
}
|
||||
|
||||
// Don't show if already dismissed in this session
|
||||
useEffect(() => {
|
||||
const dismissed = sessionStorage.getItem('pwa-prompt-dismissed')
|
||||
if (dismissed) {
|
||||
setShowPrompt(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Don't render if already installed or shouldn't show
|
||||
if (isStandalone || !showPrompt) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 z-50 md:left-auto md:right-4 md:max-w-sm">
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Download className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-sm">Install SiliconPin</h3>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleDismiss} className="h-6 w-6 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
{isIOS
|
||||
? 'Add to your home screen for a better experience'
|
||||
: 'Install our app for quick access and offline use'}
|
||||
</p>
|
||||
|
||||
{isIOS ? (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Tap the share button <span className="inline-block">📤</span> and select "Add to Home
|
||||
Screen"
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={handleInstallClick} size="sm" className="flex-1">
|
||||
{isIOS ? 'Learn How' : 'Install'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
components/RecentTopics.tsx
Normal file
136
components/RecentTopics.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { ArrowRight, BookOpen, TrendingUp, Code, Globe, Zap } from 'lucide-react'
|
||||
|
||||
// Static content for homepage
|
||||
export default function RecentTopics() {
|
||||
return (
|
||||
<section className="py-20 bg-muted/30">
|
||||
<div className="container">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="p-3 bg-primary/10 rounded-full">
|
||||
<TrendingUp className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Latest from Our Community
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
Discover insights, tutorials, and stories from our community.
|
||||
Stay updated with the latest in web development, cloud computing, and technology.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Static Content Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
|
||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-blue-500/10 rounded-full">
|
||||
<Code className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">Web Development</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3">
|
||||
Modern Development Best Practices
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Learn the latest approaches to building scalable web applications with modern frameworks and tools.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs rounded">
|
||||
React
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs rounded">
|
||||
Next.js
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-green-500/10 rounded-full">
|
||||
<Globe className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">Cloud Computing</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3">
|
||||
Scaling Applications in the Cloud
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Discover strategies for deploying and scaling applications using cloud infrastructure and services.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 text-xs rounded">
|
||||
AWS
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs rounded">
|
||||
Docker
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-purple-500/10 rounded-full">
|
||||
<Zap className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">Performance</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3">
|
||||
Optimizing for Speed and Efficiency
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Learn techniques to optimize your applications for better performance and user experience.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-xs rounded">
|
||||
Performance
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 text-xs rounded">
|
||||
Optimization
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Call to Action */}
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/blogs" className="flex items-center gap-2">
|
||||
Explore All Articles
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/blogs?q=">Search Articles</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 border-t">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Ready to share your knowledge with the community?
|
||||
</p>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/auth" className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Start Writing Today
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
70
components/StartupTest.tsx
Normal file
70
components/StartupTest.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface StartupTestProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function StartupTest({ children }: StartupTestProps) {
|
||||
const [testComplete, setTestComplete] = useState(false)
|
||||
const [testPassed, setTestPassed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Run startup test
|
||||
const runStartupTest = async () => {
|
||||
try {
|
||||
console.log('🚀 Running application startup tests...')
|
||||
const response = await fetch('/api/startup-test')
|
||||
const result = await response.json()
|
||||
|
||||
setTestPassed(result.success)
|
||||
setTestComplete(true)
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ Application startup tests passed')
|
||||
} else {
|
||||
console.warn('⚠️ Application startup tests failed:', result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to run startup tests:', error)
|
||||
setTestPassed(false)
|
||||
setTestComplete(true)
|
||||
}
|
||||
}
|
||||
|
||||
runStartupTest()
|
||||
}, [])
|
||||
|
||||
// Don't render anything special, just run the test in background
|
||||
// The test results will appear in console
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// Optional: Export a hook for components that want to check startup status
|
||||
export function useStartupStatus() {
|
||||
const [status, setStatus] = useState<{
|
||||
complete: boolean
|
||||
passed: boolean
|
||||
services?: { mongodb: boolean; redis: boolean }
|
||||
}>({ complete: false, passed: false })
|
||||
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/startup-test')
|
||||
const result = await response.json()
|
||||
setStatus({
|
||||
complete: true,
|
||||
passed: result.success,
|
||||
services: result.services,
|
||||
})
|
||||
} catch (error) {
|
||||
setStatus({ complete: true, passed: false })
|
||||
}
|
||||
}
|
||||
|
||||
checkStatus()
|
||||
}, [])
|
||||
|
||||
return status
|
||||
}
|
||||
90
components/Testimonials.tsx
Normal file
90
components/Testimonials.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Star, Quote } from 'lucide-react'
|
||||
|
||||
const Testimonials = () => {
|
||||
const testimonials = [
|
||||
{
|
||||
name: 'Alex Rodriguez',
|
||||
role: 'Senior DevOps Engineer',
|
||||
company: 'TechCorp',
|
||||
content:
|
||||
"SiliconPin's Kubernetes hosting has been a game-changer for our deployment pipeline. The auto-scaling works flawlessly and the support team is incredibly knowledgeable.",
|
||||
rating: 5,
|
||||
avatar: 'AR',
|
||||
},
|
||||
{
|
||||
name: 'Sarah Chen',
|
||||
role: 'Full Stack Developer',
|
||||
company: 'StartupXYZ',
|
||||
content:
|
||||
"The VPS performance is outstanding and the control panel makes management so easy. I've moved all my client projects here and couldn't be happier.",
|
||||
rating: 5,
|
||||
avatar: 'SC',
|
||||
},
|
||||
{
|
||||
name: 'Marcus Thompson',
|
||||
role: 'CTO',
|
||||
company: 'GrowthLab',
|
||||
content:
|
||||
'Their SMTP service has 99.9% deliverability rates. Our email campaigns have never performed better. The API integration was seamless.',
|
||||
rating: 5,
|
||||
avatar: 'MT',
|
||||
},
|
||||
{
|
||||
name: 'Emily Davis',
|
||||
role: 'Product Manager',
|
||||
company: 'InnovateCo',
|
||||
content:
|
||||
"The web tools suite is incredibly useful. The speech-to-text API powers our transcription service and it's more accurate than any other solution we've tried.",
|
||||
rating: 5,
|
||||
avatar: 'ED',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-muted/30">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4">Trusted by Developers</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
See what our customers say about SiliconPin's services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<Card key={index} className="hover:shadow-card transition-shadow duration-300">
|
||||
<CardContent className="p-8">
|
||||
<div className="flex items-center mb-4">
|
||||
{[...Array(testimonial.rating)].map((_, i) => (
|
||||
<Star key={i} className="w-4 h-4 fill-warning text-warning" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Quote className="w-8 h-8 text-primary mb-4 opacity-60" />
|
||||
|
||||
<p className="text-foreground mb-6 leading-relaxed italic">
|
||||
"{testimonial.content}"
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-gradient-hero rounded-full flex items-center justify-center text-white font-semibold">
|
||||
{testimonial.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">{testimonial.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{testimonial.role} at {testimonial.company}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Testimonials
|
||||
188
components/admin/AdminLayout.tsx
Normal file
188
components/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
CreditCard,
|
||||
Settings,
|
||||
FileText,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
BarChart3,
|
||||
Download,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/admin',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
href: '/admin/users',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: 'Billing',
|
||||
href: '/admin/billing',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: 'Services',
|
||||
href: '/admin/services',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
name: 'Analytics',
|
||||
href: '/admin/analytics',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
href: '/admin/reports',
|
||||
icon: Download,
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/admin/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
const handleLogout = () => {
|
||||
// Clear auth tokens and redirect
|
||||
document.cookie = 'token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT'
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/auth'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-blue-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
{/* Mobile sidebar overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black bg-opacity-50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<Link href="/admin" className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center shadow-lg">
|
||||
<span className="text-white font-bold text-sm">SP</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900 dark:text-white">Admin</span>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href || (item.href !== '/admin' && pathname.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 group',
|
||||
isActive
|
||||
? 'bg-blue-600 dark:bg-blue-500 text-white shadow-md'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
)}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn(
|
||||
'mr-3 h-5 w-5 transition-colors',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-300'
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all duration-200"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-30 bg-white/90 dark:bg-gray-900/90 backdrop-blur-md border-b border-gray-200 dark:border-gray-700 px-4 py-3 lg:px-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="lg:hidden text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
SiliconPin Admin Panel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-4 lg:p-6">
|
||||
<div className="max-w-7xl mx-auto">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
567
components/admin/BillingManagement.tsx
Normal file
567
components/admin/BillingManagement.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Search,
|
||||
Edit,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
DollarSign,
|
||||
} from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface BillingRecord {
|
||||
_id: string
|
||||
user_email: string
|
||||
silicon_id: string
|
||||
service_type: string
|
||||
service_name: string
|
||||
amount: number
|
||||
tax_amount: number
|
||||
discount_applied: number
|
||||
payment_status: 'pending' | 'completed' | 'failed' | 'refunded'
|
||||
service_status: 'active' | 'inactive' | 'suspended' | 'cancelled'
|
||||
billing_cycle: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface BillingSummary {
|
||||
totalAmount: number
|
||||
totalTax: number
|
||||
totalDiscount: number
|
||||
completedCount: number
|
||||
pendingCount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
export default function BillingManagement() {
|
||||
const [billings, setBillings] = useState<BillingRecord[]>([])
|
||||
const [summary, setSummary] = useState<BillingSummary | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [serviceTypeFilter, setServiceTypeFilter] = useState('all')
|
||||
const [paymentStatusFilter, setPaymentStatusFilter] = useState('all')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [editingBilling, setEditingBilling] = useState<BillingRecord | null>(null)
|
||||
const [editForm, setEditForm] = useState({
|
||||
payment_status: 'pending' as 'pending' | 'completed' | 'failed' | 'refunded',
|
||||
service_status: 'active' as 'active' | 'inactive' | 'suspended' | 'cancelled',
|
||||
amount: 0,
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const fetchBillings = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
limit: '20',
|
||||
search,
|
||||
serviceType: serviceTypeFilter,
|
||||
paymentStatus: paymentStatusFilter,
|
||||
...(dateFrom && { dateFrom }),
|
||||
...(dateTo && { dateTo }),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/admin/billing?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch billing data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setBillings(data.billings)
|
||||
setSummary(data.summary)
|
||||
setTotalPages(data.pagination.totalPages)
|
||||
} catch (error) {
|
||||
console.error('Billing fetch error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load billing data',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, serviceTypeFilter, paymentStatusFilter, dateFrom, dateTo, currentPage])
|
||||
|
||||
useEffect(() => {
|
||||
fetchBillings()
|
||||
}, [fetchBillings])
|
||||
|
||||
const handleEditBilling = (billing: BillingRecord) => {
|
||||
setEditingBilling(billing)
|
||||
setEditForm({
|
||||
payment_status: billing.payment_status,
|
||||
service_status: billing.service_status,
|
||||
amount: billing.amount,
|
||||
notes: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateBilling = async () => {
|
||||
if (!editingBilling) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/billing', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'update',
|
||||
billingId: editingBilling._id,
|
||||
data: editForm,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update billing record')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setBillings(billings.map((b) => (b._id === editingBilling._id ? data.billing : b)))
|
||||
setEditingBilling(null)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Billing record updated successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Billing update error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update billing record',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefund = async (billingId: string) => {
|
||||
if (!confirm('Are you sure you want to process a refund for this billing record?')) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/billing', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'refund',
|
||||
billingId,
|
||||
data: { refundReason: 'Admin refund' },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to process refund')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setBillings(billings.map((b) => (b._id === billingId ? data.billing : b)))
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: data.message,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Refund error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to process refund',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'refunded':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
case 'inactive':
|
||||
case 'suspended':
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Revenue</p>
|
||||
<p className="text-2xl font-bold">{formatCurrency(summary.totalAmount)}</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Completed</p>
|
||||
<p className="text-2xl font-bold text-green-600">{summary.completedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Pending</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{summary.pendingCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.failedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={serviceTypeFilter} onValueChange={setServiceTypeFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Service Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Services</SelectItem>
|
||||
<SelectItem value="vps_deployment">VPS</SelectItem>
|
||||
<SelectItem value="kubernetes_deployment">Kubernetes</SelectItem>
|
||||
<SelectItem value="developer_hire">Developer Hire</SelectItem>
|
||||
<SelectItem value="vpn_service">VPN</SelectItem>
|
||||
<SelectItem value="hosting_config">Hosting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={paymentStatusFilter} onValueChange={setPaymentStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Payment Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
<SelectItem value="refunded">Refunded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="date"
|
||||
placeholder="From Date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
placeholder="To Date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Billing Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4">Customer</th>
|
||||
<th className="text-left py-3 px-4">Service</th>
|
||||
<th className="text-left py-3 px-4">Amount</th>
|
||||
<th className="text-left py-3 px-4">Payment Status</th>
|
||||
<th className="text-left py-3 px-4">Service Status</th>
|
||||
<th className="text-left py-3 px-4">Date</th>
|
||||
<th className="text-left py-3 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8">
|
||||
Loading billing data...
|
||||
</td>
|
||||
</tr>
|
||||
) : billings.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-500">
|
||||
No billing records found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
billings.map((billing) => (
|
||||
<tr key={billing._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{billing.user_email}</p>
|
||||
<p className="text-sm text-gray-500">ID: {billing.silicon_id}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{billing.service_name}</p>
|
||||
<p className="text-sm text-gray-500 capitalize">
|
||||
{billing.service_type.replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{formatCurrency(billing.amount)}</p>
|
||||
{billing.tax_amount > 0 && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Tax: {formatCurrency(billing.tax_amount)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge className={getStatusBadgeColor(billing.payment_status)}>
|
||||
{billing.payment_status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge className={getStatusBadgeColor(billing.service_status)}>
|
||||
{billing.service_status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{billing.created_at
|
||||
? formatDistanceToNow(new Date(billing.created_at), { addSuffix: true })
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditBilling(billing)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{billing.payment_status === 'completed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRefund(billing._id)}
|
||||
className="text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Billing Dialog */}
|
||||
<Dialog open={!!editingBilling} onOpenChange={() => setEditingBilling(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Billing Record</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="payment_status">Payment Status</Label>
|
||||
<Select
|
||||
value={editForm.payment_status}
|
||||
onValueChange={(value: 'pending' | 'completed' | 'failed' | 'refunded') =>
|
||||
setEditForm({ ...editForm, payment_status: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
<SelectItem value="refunded">Refunded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="service_status">Service Status</Label>
|
||||
<Select
|
||||
value={editForm.service_status}
|
||||
onValueChange={(value: 'active' | 'inactive' | 'suspended' | 'cancelled') =>
|
||||
setEditForm({ ...editForm, service_status: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="suspended">Suspended</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="amount">Amount</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
value={editForm.amount}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, amount: parseFloat(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={editForm.notes}
|
||||
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
|
||||
placeholder="Add notes about this update..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setEditingBilling(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateBilling}>Update Record</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
components/admin/DashboardStats.tsx
Normal file
130
components/admin/DashboardStats.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, DollarSign, CreditCard, TrendingUp, UserCheck, Activity } from 'lucide-react'
|
||||
|
||||
interface DashboardStatsProps {
|
||||
stats: {
|
||||
users: {
|
||||
total: number
|
||||
active: number
|
||||
newThisMonth: number
|
||||
verified: number
|
||||
verificationRate: string
|
||||
}
|
||||
revenue: {
|
||||
total: number
|
||||
monthly: number
|
||||
weekly: number
|
||||
}
|
||||
transactions: {
|
||||
total: number
|
||||
monthly: number
|
||||
}
|
||||
services: {
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
developerRequests: {
|
||||
total: number
|
||||
pending: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashboardStats({ stats }: DashboardStatsProps) {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: 'Total Users',
|
||||
value: stats.users.total.toLocaleString(),
|
||||
change: `+${stats.users.newThisMonth} this month`,
|
||||
icon: Users,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
title: 'Total Revenue',
|
||||
value: formatCurrency(stats.revenue.total),
|
||||
change: `${formatCurrency(stats.revenue.monthly)} this month`,
|
||||
icon: DollarSign,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
title: 'Active Services',
|
||||
value: stats.services.active.toLocaleString(),
|
||||
change: `${stats.services.total} total services`,
|
||||
icon: Activity,
|
||||
color: 'text-purple-600',
|
||||
},
|
||||
{
|
||||
title: 'Verified Users',
|
||||
value: `${stats.users.verificationRate}%`,
|
||||
change: `${stats.users.verified} verified`,
|
||||
icon: UserCheck,
|
||||
color: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
title: 'Transactions',
|
||||
value: stats.transactions.total.toLocaleString(),
|
||||
change: `+${stats.transactions.monthly} this month`,
|
||||
icon: CreditCard,
|
||||
color: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
title: 'Developer Requests',
|
||||
value: stats.developerRequests.total.toLocaleString(),
|
||||
change: `${stats.developerRequests.pending} pending`,
|
||||
icon: TrendingUp,
|
||||
color: 'text-indigo-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{statCards.map((card, index) => {
|
||||
const gradients = [
|
||||
'from-blue-500 to-blue-600',
|
||||
'from-green-500 to-emerald-600',
|
||||
'from-purple-500 to-indigo-600',
|
||||
'from-emerald-500 to-teal-600',
|
||||
'from-orange-500 to-red-500',
|
||||
'from-indigo-500 to-purple-600',
|
||||
]
|
||||
const bgGradient = gradients[index % gradients.length]
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className="border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-r ${bgGradient} shadow-lg`}>
|
||||
<card.icon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{card.value}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||
{card.change}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
components/admin/RecentActivity.tsx
Normal file
137
components/admin/RecentActivity.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface RecentActivityProps {
|
||||
recentUsers: Array<{
|
||||
_id: string
|
||||
name: string
|
||||
email: string
|
||||
siliconId: string
|
||||
createdAt: string
|
||||
}>
|
||||
recentTransactions: Array<{
|
||||
_id: string
|
||||
type: string
|
||||
amount: number
|
||||
description: string
|
||||
status: string
|
||||
createdAt: string
|
||||
userId: {
|
||||
name: string
|
||||
email: string
|
||||
siliconId: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
export default function RecentActivity({ recentUsers, recentTransactions }: RecentActivityProps) {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const getTransactionBadgeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'credit':
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'
|
||||
case 'debit':
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300'
|
||||
default:
|
||||
return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Users */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Recent Users
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentUsers.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No recent users</p>
|
||||
) : (
|
||||
recentUsers.map((user) => (
|
||||
<div
|
||||
key={user._id}
|
||||
className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-b-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{user.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{user.email}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">ID: {user.siliconId}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{user.createdAt
|
||||
? formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Transactions */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Recent Transactions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentTransactions.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No recent transactions</p>
|
||||
) : (
|
||||
recentTransactions.map((transaction) => (
|
||||
<div
|
||||
key={transaction._id}
|
||||
className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-b-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={getTransactionBadgeColor(transaction.type)}>
|
||||
{transaction.type}
|
||||
</Badge>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{formatCurrency(transaction.amount)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{transaction.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{transaction.userId?.name} ({transaction.userId?.siliconId})
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{transaction.createdAt
|
||||
? formatDistanceToNow(new Date(transaction.createdAt), { addSuffix: true })
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
components/admin/ReportsManagement.tsx
Normal file
284
components/admin/ReportsManagement.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Download, FileText, Users, CreditCard, Activity, BarChart3 } from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
|
||||
const reportTypes = [
|
||||
{
|
||||
id: 'users',
|
||||
name: 'Users Report',
|
||||
description: 'Export all user data including registration info, roles, and verification status',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: 'transactions',
|
||||
name: 'Transactions Report',
|
||||
description: 'Export all transaction data including payments, refunds, and balance changes',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
name: 'Billing Report',
|
||||
description: 'Export billing records including service purchases and payment status',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
id: 'developer-requests',
|
||||
name: 'Developer Requests Report',
|
||||
description: 'Export developer hire requests and their status',
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
name: 'Summary Report',
|
||||
description: 'Comprehensive overview with key metrics and statistics',
|
||||
icon: BarChart3,
|
||||
},
|
||||
]
|
||||
|
||||
export default function ReportsManagement() {
|
||||
const [selectedReport, setSelectedReport] = useState('')
|
||||
const [format, setFormat] = useState('json')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!selectedReport) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select a report type',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const params = new URLSearchParams({
|
||||
type: selectedReport,
|
||||
format,
|
||||
...(dateFrom && { dateFrom }),
|
||||
...(dateTo && { dateTo }),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/admin/reports?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate report')
|
||||
}
|
||||
|
||||
if (format === 'csv') {
|
||||
// Handle CSV download
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedReport}_report_${new Date().toISOString().split('T')[0]}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} else {
|
||||
// Handle JSON download
|
||||
const data = await response.json()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedReport}_report_${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Report generated and downloaded successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Report generation error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to generate report',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickExport = async (reportType: string) => {
|
||||
setSelectedReport(reportType)
|
||||
setFormat('csv')
|
||||
|
||||
// Trigger immediate export
|
||||
setTimeout(() => {
|
||||
handleGenerateReport()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Export Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{reportTypes.map((report) => (
|
||||
<Card key={report.id} className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-2">
|
||||
<report.icon className="h-5 w-5 text-blue-600 mr-2" />
|
||||
<h3 className="font-semibold">{report.name}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">{report.description}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleQuickExport(report.id)}
|
||||
disabled={generating}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Quick Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Report Generator */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Custom Report Generator</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="reportType">Report Type</Label>
|
||||
<Select value={selectedReport} onValueChange={setSelectedReport}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select report type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reportTypes.map((report) => (
|
||||
<SelectItem key={report.id} value={report.id}>
|
||||
{report.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="format">Format</Label>
|
||||
<Select value={format} onValueChange={setFormat}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="dateFrom">From Date</Label>
|
||||
<Input
|
||||
id="dateFrom"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="dateTo">To Date</Label>
|
||||
<Input
|
||||
id="dateTo"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleGenerateReport} disabled={generating || !selectedReport}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{generating ? 'Generating...' : 'Generate Report'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Report Guidelines */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Report Guidelines</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">Data Privacy</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Exported reports contain sensitive user data. Ensure compliance with data protection
|
||||
regulations and handle all exported data securely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">File Formats</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>
|
||||
• <strong>JSON:</strong> Complete data with full structure, suitable for technical
|
||||
analysis
|
||||
</li>
|
||||
<li>
|
||||
• <strong>CSV:</strong> Simplified tabular format, suitable for spreadsheet
|
||||
applications
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">Date Filtering</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Use date filters to limit report scope. Large date ranges may result in slower
|
||||
generation times and larger file sizes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
665
components/admin/ServiceManagement.tsx
Normal file
665
components/admin/ServiceManagement.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Edit, X, ChevronLeft, ChevronRight, Server, Users, Activity } from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface BillingService {
|
||||
_id: string
|
||||
user_email: string
|
||||
silicon_id: string
|
||||
service_type: string
|
||||
service_name: string
|
||||
amount: number
|
||||
service_status: 'active' | 'inactive' | 'suspended' | 'cancelled'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface DeveloperRequest {
|
||||
_id: string
|
||||
userId: {
|
||||
name: string
|
||||
email: string
|
||||
siliconId: string
|
||||
}
|
||||
planType: string
|
||||
projectDescription: string
|
||||
status: 'pending' | 'approved' | 'in_progress' | 'completed' | 'cancelled'
|
||||
assignedDeveloper?:
|
||||
| {
|
||||
name: string
|
||||
email: string
|
||||
skills: string[]
|
||||
}
|
||||
| string
|
||||
estimatedCompletionDate?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ServiceStats {
|
||||
_id: string
|
||||
count: number
|
||||
revenue: number
|
||||
activeServices: number
|
||||
}
|
||||
|
||||
export default function ServiceManagement() {
|
||||
const [billingServices, setBillingServices] = useState<BillingService[]>([])
|
||||
const [developerRequests, setDeveloperRequests] = useState<DeveloperRequest[]>([])
|
||||
const [serviceStats, setServiceStats] = useState<ServiceStats[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [serviceTypeFilter, setServiceTypeFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [editingService, setEditingService] = useState<any>(null)
|
||||
const [editForm, setEditForm] = useState({
|
||||
status: '',
|
||||
assignedDeveloper: '',
|
||||
estimatedCompletionDate: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const fetchServices = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
limit: '20',
|
||||
serviceType: serviceTypeFilter,
|
||||
status: statusFilter,
|
||||
...(dateFrom && { dateFrom }),
|
||||
...(dateTo && { dateTo }),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/admin/services?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch services data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setBillingServices(data.billingServices)
|
||||
setDeveloperRequests(data.developerRequests)
|
||||
setServiceStats(data.serviceStats)
|
||||
setTotalPages(data.pagination.totalPages)
|
||||
} catch (error) {
|
||||
console.error('Services fetch error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load services data',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serviceTypeFilter, statusFilter, dateFrom, dateTo, currentPage])
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices()
|
||||
}, [fetchServices])
|
||||
|
||||
const handleEditService = (service: any, type: 'billing' | 'developer') => {
|
||||
setEditingService({ ...service, type })
|
||||
if (type === 'billing') {
|
||||
setEditForm({
|
||||
status: service.service_status,
|
||||
assignedDeveloper: '',
|
||||
estimatedCompletionDate: '',
|
||||
notes: '',
|
||||
})
|
||||
} else {
|
||||
setEditForm({
|
||||
status: service.status,
|
||||
assignedDeveloper:
|
||||
typeof service.assignedDeveloper === 'object' && service.assignedDeveloper?.name
|
||||
? service.assignedDeveloper.name
|
||||
: typeof service.assignedDeveloper === 'string'
|
||||
? service.assignedDeveloper
|
||||
: '',
|
||||
estimatedCompletionDate: service.estimatedCompletionDate || '',
|
||||
notes: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateService = async () => {
|
||||
if (!editingService) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const action = editingService.type === 'billing' ? 'updateBilling' : 'updateDeveloperRequest'
|
||||
|
||||
const response = await fetch('/api/admin/services', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
serviceId: editingService._id,
|
||||
serviceType: editingService.type,
|
||||
data:
|
||||
editingService.type === 'billing'
|
||||
? { service_status: editForm.status }
|
||||
: {
|
||||
status: editForm.status,
|
||||
assignedDeveloper: editForm.assignedDeveloper,
|
||||
estimatedCompletionDate: editForm.estimatedCompletionDate,
|
||||
notes: editForm.notes,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update service')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (editingService.type === 'billing') {
|
||||
setBillingServices(
|
||||
billingServices.map((s) => (s._id === editingService._id ? data.service : s))
|
||||
)
|
||||
} else {
|
||||
setDeveloperRequests(
|
||||
developerRequests.map((s) => (s._id === editingService._id ? data.service : s))
|
||||
)
|
||||
}
|
||||
|
||||
setEditingService(null)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Service updated successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Service update error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update service',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelService = async (serviceId: string, type: 'billing' | 'developer') => {
|
||||
if (!confirm('Are you sure you want to cancel this service?')) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/services', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'cancelService',
|
||||
serviceId,
|
||||
serviceType: type,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to cancel service')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (type === 'billing') {
|
||||
setBillingServices(billingServices.map((s) => (s._id === serviceId ? data.service : s)))
|
||||
} else {
|
||||
setDeveloperRequests(developerRequests.map((s) => (s._id === serviceId ? data.service : s)))
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Service cancelled successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Service cancel error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to cancel service',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'pending':
|
||||
case 'approved':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'in_progress':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'cancelled':
|
||||
case 'inactive':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'suspended':
|
||||
return 'bg-orange-100 text-orange-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Service Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{serviceStats.map((stat) => (
|
||||
<Card key={stat._id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 capitalize">{stat._id.replace('_', ' ')}</p>
|
||||
<p className="text-2xl font-bold">{stat.count}</p>
|
||||
<p className="text-sm text-green-600">
|
||||
{stat.activeServices} active • {formatCurrency(stat.revenue)}
|
||||
</p>
|
||||
</div>
|
||||
<Server className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Service Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Select value={serviceTypeFilter} onValueChange={setServiceTypeFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Service Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Services</SelectItem>
|
||||
<SelectItem value="vps_deployment">VPS</SelectItem>
|
||||
<SelectItem value="kubernetes_deployment">Kubernetes</SelectItem>
|
||||
<SelectItem value="developer_hire">Developer Hire</SelectItem>
|
||||
<SelectItem value="vpn_service">VPN</SelectItem>
|
||||
<SelectItem value="hosting_config">Hosting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="date"
|
||||
placeholder="From Date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
placeholder="To Date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Billing Services */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
Billing Services
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4">Customer</th>
|
||||
<th className="text-left py-3 px-4">Service</th>
|
||||
<th className="text-left py-3 px-4">Amount</th>
|
||||
<th className="text-left py-3 px-4">Status</th>
|
||||
<th className="text-left py-3 px-4">Date</th>
|
||||
<th className="text-left py-3 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{billingServices.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-500">
|
||||
No billing services found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
billingServices.map((service) => (
|
||||
<tr key={service._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{service.user_email}</p>
|
||||
<p className="text-sm text-gray-500">ID: {service.silicon_id}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{service.service_name}</p>
|
||||
<p className="text-sm text-gray-500 capitalize">
|
||||
{service.service_type.replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium">{formatCurrency(service.amount)}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge className={getStatusBadgeColor(service.service_status)}>
|
||||
{service.service_status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{service.created_at
|
||||
? formatDistanceToNow(new Date(service.created_at), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditService(service, 'billing')}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCancelService(service._id, 'billing')}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Developer Requests */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Users className="h-5 w-5 mr-2" />
|
||||
Developer Requests
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4">Customer</th>
|
||||
<th className="text-left py-3 px-4">Plan</th>
|
||||
<th className="text-left py-3 px-4">Description</th>
|
||||
<th className="text-left py-3 px-4">Status</th>
|
||||
<th className="text-left py-3 px-4">Developer</th>
|
||||
<th className="text-left py-3 px-4">Date</th>
|
||||
<th className="text-left py-3 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{developerRequests.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-500">
|
||||
No developer requests found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
developerRequests.map((request) => (
|
||||
<tr key={request._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{request.userId.name}</p>
|
||||
<p className="text-sm text-gray-500">{request.userId.email}</p>
|
||||
<p className="text-xs text-gray-400">ID: {request.userId.siliconId}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="outline">{request.planType}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<p className="text-sm max-w-xs truncate">{request.projectDescription}</p>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge className={getStatusBadgeColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm">
|
||||
{typeof request.assignedDeveloper === 'object' &&
|
||||
request.assignedDeveloper?.name
|
||||
? request.assignedDeveloper.name
|
||||
: typeof request.assignedDeveloper === 'string'
|
||||
? request.assignedDeveloper
|
||||
: 'Unassigned'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{request.createdAt
|
||||
? formatDistanceToNow(new Date(request.createdAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditService(request, 'developer')}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCancelService(request._id, 'developer')}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Service Dialog */}
|
||||
<Dialog open={!!editingService} onOpenChange={() => setEditingService(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit {editingService?.type === 'billing' ? 'Service' : 'Developer Request'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={editForm.status}
|
||||
onValueChange={(value) => setEditForm({ ...editForm, status: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{editingService?.type === 'billing' ? (
|
||||
<>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="suspended">Suspended</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{editingService?.type === 'developer' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="assignedDeveloper">Assigned Developer</Label>
|
||||
<Input
|
||||
id="assignedDeveloper"
|
||||
value={editForm.assignedDeveloper}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, assignedDeveloper: e.target.value })
|
||||
}
|
||||
placeholder="Developer name or ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="estimatedCompletionDate">Estimated Completion Date</Label>
|
||||
<Input
|
||||
id="estimatedCompletionDate"
|
||||
type="date"
|
||||
value={editForm.estimatedCompletionDate}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, estimatedCompletionDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={editForm.notes}
|
||||
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
|
||||
placeholder="Add notes about this update..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setEditingService(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateService}>Update Service</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
592
components/admin/SystemSettings.tsx
Normal file
592
components/admin/SystemSettings.tsx
Normal file
@@ -0,0 +1,592 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Settings, Save, RefreshCw, Database, Bell, Shield, Users, Activity } from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface SystemSettingsData {
|
||||
maintenanceMode: boolean
|
||||
registrationEnabled: boolean
|
||||
emailVerificationRequired: boolean
|
||||
maxUserBalance: number
|
||||
defaultUserRole: 'user' | 'admin'
|
||||
systemMessage: string
|
||||
paymentGatewayEnabled: boolean
|
||||
developerHireEnabled: boolean
|
||||
vpsDeploymentEnabled: boolean
|
||||
kubernetesDeploymentEnabled: boolean
|
||||
vpnServiceEnabled: boolean
|
||||
lastUpdated: string
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
interface SystemStatistics {
|
||||
totalUsers: number
|
||||
adminUsers: number
|
||||
verifiedUsers: number
|
||||
unverifiedUsers: number
|
||||
}
|
||||
|
||||
interface RecentActivity {
|
||||
id: string
|
||||
action: string
|
||||
details: string
|
||||
timestamp: string
|
||||
adminName: string
|
||||
}
|
||||
|
||||
export default function SystemSettings() {
|
||||
const [settings, setSettings] = useState<SystemSettingsData | null>(null)
|
||||
const [statistics, setStatistics] = useState<SystemStatistics | null>(null)
|
||||
const [recentActivities, setRecentActivities] = useState<RecentActivity[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [notificationDialog, setNotificationDialog] = useState(false)
|
||||
const [notificationForm, setNotificationForm] = useState({
|
||||
message: '',
|
||||
targetUsers: 'all',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [])
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch settings')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSettings(data.settings)
|
||||
setStatistics(data.statistics)
|
||||
setRecentActivities(data.recentActivities)
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load system settings',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
if (!settings) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'updateSettings',
|
||||
settings,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update settings')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSettings(data.settings)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: data.message,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Settings update error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update settings',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSystemAction = async (action: string, additionalData?: any) => {
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
...additionalData,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${action}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: data.message,
|
||||
})
|
||||
|
||||
if (action === 'sendSystemNotification') {
|
||||
setNotificationDialog(false)
|
||||
setNotificationForm({ message: '', targetUsers: 'all' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${action} error:`, error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to ${action}`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendNotification = () => {
|
||||
handleSystemAction('sendSystemNotification', notificationForm)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!settings || !statistics) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-gray-500">Failed to load system settings</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* System Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Users</p>
|
||||
<p className="text-2xl font-bold">{statistics.totalUsers}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Admin Users</p>
|
||||
<p className="text-2xl font-bold">{statistics.adminUsers}</p>
|
||||
</div>
|
||||
<Shield className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Verified Users</p>
|
||||
<p className="text-2xl font-bold">{statistics.verifiedUsers}</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Unverified Users</p>
|
||||
<p className="text-2xl font-bold">{statistics.unverifiedUsers}</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* System Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
System Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* General Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">General</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="maintenanceMode">Maintenance Mode</Label>
|
||||
<p className="text-sm text-gray-500">Disable access for non-admin users</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="maintenanceMode"
|
||||
checked={settings.maintenanceMode}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, maintenanceMode: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="registrationEnabled">Registration Enabled</Label>
|
||||
<p className="text-sm text-gray-500">Allow new user registrations</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="registrationEnabled"
|
||||
checked={settings.registrationEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, registrationEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="emailVerificationRequired">Email Verification Required</Label>
|
||||
<p className="text-sm text-gray-500">Require email verification for new users</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="emailVerificationRequired"
|
||||
checked={settings.emailVerificationRequired}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, emailVerificationRequired: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="maxUserBalance">Max User Balance (₹)</Label>
|
||||
<Input
|
||||
id="maxUserBalance"
|
||||
type="number"
|
||||
value={settings.maxUserBalance}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, maxUserBalance: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="defaultUserRole">Default User Role</Label>
|
||||
<Select
|
||||
value={settings.defaultUserRole}
|
||||
onValueChange={(value: 'user' | 'admin') =>
|
||||
setSettings({ ...settings, defaultUserRole: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="systemMessage">System Message</Label>
|
||||
<Textarea
|
||||
id="systemMessage"
|
||||
value={settings.systemMessage}
|
||||
onChange={(e) => setSettings({ ...settings, systemMessage: e.target.value })}
|
||||
placeholder="System-wide message for users..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Services</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="paymentGatewayEnabled">Payment Gateway</Label>
|
||||
<p className="text-sm text-gray-500">Enable payment processing</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="paymentGatewayEnabled"
|
||||
checked={settings.paymentGatewayEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, paymentGatewayEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="developerHireEnabled">Developer Hire Service</Label>
|
||||
<p className="text-sm text-gray-500">Enable developer hiring</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="developerHireEnabled"
|
||||
checked={settings.developerHireEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, developerHireEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="vpsDeploymentEnabled">VPS Deployment</Label>
|
||||
<p className="text-sm text-gray-500">Enable VPS deployments</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="vpsDeploymentEnabled"
|
||||
checked={settings.vpsDeploymentEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, vpsDeploymentEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="kubernetesDeploymentEnabled">Kubernetes Deployment</Label>
|
||||
<p className="text-sm text-gray-500">Enable Kubernetes deployments</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="kubernetesDeploymentEnabled"
|
||||
checked={settings.kubernetesDeploymentEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, kubernetesDeploymentEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="vpnServiceEnabled">VPN Service</Label>
|
||||
<p className="text-sm text-gray-500">Enable VPN services</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="vpnServiceEnabled"
|
||||
checked={settings.vpnServiceEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, vpnServiceEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveSettings} disabled={saving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{settings.lastUpdated && (
|
||||
<div className="text-sm text-gray-500 border-t pt-4">
|
||||
Last updated{' '}
|
||||
{settings.lastUpdated
|
||||
? formatDistanceToNow(new Date(settings.lastUpdated), { addSuffix: true })
|
||||
: 'N/A'}
|
||||
by {settings.updatedBy}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Actions & Recent Activity */}
|
||||
<div className="space-y-6">
|
||||
{/* System Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleSystemAction('clearCache')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Clear System Cache
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleSystemAction('backupDatabase')}
|
||||
>
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Backup Database
|
||||
</Button>
|
||||
|
||||
<Dialog open={notificationDialog} onOpenChange={setNotificationDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Send System Notification
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send System Notification</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={notificationForm.message}
|
||||
onChange={(e) =>
|
||||
setNotificationForm({ ...notificationForm, message: e.target.value })
|
||||
}
|
||||
placeholder="Enter notification message..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="targetUsers">Target Users</Label>
|
||||
<Select
|
||||
value={notificationForm.targetUsers}
|
||||
onValueChange={(value) =>
|
||||
setNotificationForm({ ...notificationForm, targetUsers: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Users</SelectItem>
|
||||
<SelectItem value="verified">Verified Users</SelectItem>
|
||||
<SelectItem value="unverified">Unverified Users</SelectItem>
|
||||
<SelectItem value="admins">Admin Users</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setNotificationDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSendNotification}>Send Notification</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activities */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentActivities.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No recent activities</p>
|
||||
) : (
|
||||
recentActivities.map((activity) => (
|
||||
<div key={activity.id} className="border-b last:border-b-0 pb-3 last:pb-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{activity.action}</p>
|
||||
<p className="text-sm text-gray-600">{activity.details}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
by {activity.adminName} •{' '}
|
||||
{activity.timestamp
|
||||
? formatDistanceToNow(new Date(activity.timestamp), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
586
components/admin/UserManagement.tsx
Normal file
586
components/admin/UserManagement.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Search, Edit, Trash2, UserPlus, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface User {
|
||||
_id: string
|
||||
name: string
|
||||
email: string
|
||||
siliconId: string
|
||||
role: 'user' | 'admin'
|
||||
isVerified: boolean
|
||||
balance: number
|
||||
lastLogin: string
|
||||
createdAt: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface UserManagementProps {
|
||||
initialUsers?: User[]
|
||||
}
|
||||
|
||||
export default function UserManagement({ initialUsers = [] }: UserManagementProps) {
|
||||
const [users, setUsers] = useState<User[]>(initialUsers)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState('all')
|
||||
const [verifiedFilter, setVerifiedFilter] = useState('all')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'user' as 'user' | 'admin',
|
||||
isVerified: false,
|
||||
balance: 0,
|
||||
})
|
||||
const [addForm, setAddForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user' as 'user' | 'admin',
|
||||
isVerified: false,
|
||||
balance: 0,
|
||||
})
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
limit: '20',
|
||||
search,
|
||||
role: roleFilter,
|
||||
verified: verifiedFilter,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/admin/users?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch users')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setUsers(data.users)
|
||||
setTotalPages(data.pagination.totalPages)
|
||||
} catch (error) {
|
||||
console.error('Users fetch error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load users',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, roleFilter, verifiedFilter, currentPage])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setEditForm({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isVerified: user.isVerified,
|
||||
balance: user.balance,
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateUser = async () => {
|
||||
if (!editingUser) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'update',
|
||||
userId: editingUser._id,
|
||||
data: editForm,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update user')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setUsers(users.map((u) => (u._id === editingUser._id ? data.user : u)))
|
||||
setEditingUser(null)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User updated successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('User update error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update user',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'delete',
|
||||
userId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete user')
|
||||
}
|
||||
|
||||
setUsers(users.filter((u) => u._id !== userId))
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User deleted successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete user',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddUser = async () => {
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'create',
|
||||
data: addForm,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create user')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setUsers([data.user, ...users])
|
||||
setShowAddDialog(false)
|
||||
setAddForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
isVerified: false,
|
||||
balance: 0,
|
||||
})
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User created successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to create user',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>User Management</CardTitle>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Roles</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={verifiedFilter} onValueChange={setVerifiedFilter}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Verified" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Users</SelectItem>
|
||||
<SelectItem value="true">Verified</SelectItem>
|
||||
<SelectItem value="false">Unverified</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4">User</th>
|
||||
<th className="text-left py-3 px-4">Role</th>
|
||||
<th className="text-left py-3 px-4">Status</th>
|
||||
<th className="text-left py-3 px-4">Balance</th>
|
||||
<th className="text-left py-3 px-4">Last Login</th>
|
||||
<th className="text-left py-3 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8">
|
||||
Loading users...
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p className="text-xs text-gray-400">ID: {user.siliconId}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={user.isVerified ? 'default' : 'destructive'}>
|
||||
{user.isVerified ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
<Badge variant="outline">{user.provider}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium">{formatCurrency(user.balance)}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{user.lastLogin
|
||||
? formatDistanceToNow(new Date(user.lastLogin), { addSuffix: true })
|
||||
: 'Never'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleEditUser(user)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user._id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add User Dialog */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="add-name">Name</Label>
|
||||
<Input
|
||||
id="add-name"
|
||||
value={addForm.name}
|
||||
onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
|
||||
placeholder="Enter user name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="add-email">Email</Label>
|
||||
<Input
|
||||
id="add-email"
|
||||
type="email"
|
||||
value={addForm.email}
|
||||
onChange={(e) => setAddForm({ ...addForm, email: e.target.value })}
|
||||
placeholder="Enter email address"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="add-password">Password</Label>
|
||||
<Input
|
||||
id="add-password"
|
||||
type="password"
|
||||
value={addForm.password}
|
||||
onChange={(e) => setAddForm({ ...addForm, password: e.target.value })}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="add-role">Role</Label>
|
||||
<Select
|
||||
value={addForm.role}
|
||||
onValueChange={(value: 'user' | 'admin') =>
|
||||
setAddForm({ ...addForm, role: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="add-balance">Initial Balance (₹)</Label>
|
||||
<Input
|
||||
id="add-balance"
|
||||
type="number"
|
||||
value={addForm.balance}
|
||||
onChange={(e) =>
|
||||
setAddForm({ ...addForm, balance: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="add-verified"
|
||||
checked={addForm.isVerified}
|
||||
onCheckedChange={(checked) => setAddForm({ ...addForm, isVerified: checked })}
|
||||
/>
|
||||
<Label htmlFor="add-verified">Verified</Label>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddUser}>Create User</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<Dialog open={!!editingUser} onOpenChange={() => setEditingUser(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(value: 'user' | 'admin') =>
|
||||
setEditForm({ ...editForm, role: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="balance">Balance (₹)</Label>
|
||||
<Input
|
||||
id="balance"
|
||||
type="number"
|
||||
value={editForm.balance}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, balance: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="verified"
|
||||
checked={editForm.isVerified}
|
||||
onCheckedChange={(checked) => setEditForm({ ...editForm, isVerified: checked })}
|
||||
/>
|
||||
<Label htmlFor="verified">Verified</Label>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setEditingUser(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateUser}>Update User</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
47
components/auth/GitHubSignInButton.tsx
Normal file
47
components/auth/GitHubSignInButton.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Github } from 'lucide-react'
|
||||
|
||||
interface GitHubSignInButtonProps {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function GitHubSignInButton({ className, disabled }: GitHubSignInButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleGitHubSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// Get GitHub OAuth URL from our API
|
||||
const response = await fetch('/api/auth/github')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.data.authURL) {
|
||||
// Redirect to GitHub OAuth
|
||||
window.location.href = data.data.authURL
|
||||
} else {
|
||||
console.error('Failed to get GitHub auth URL:', data.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('GitHub sign-in error:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleGitHubSignIn}
|
||||
disabled={disabled || isLoading}
|
||||
className={className}
|
||||
>
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
{isLoading ? 'Signing in...' : 'Continue with GitHub'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
63
components/auth/GoogleSignInButton.tsx
Normal file
63
components/auth/GoogleSignInButton.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface GoogleSignInButtonProps {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function GoogleSignInButton({ className, disabled }: GoogleSignInButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// Get Google OAuth URL from our API
|
||||
const response = await fetch('/api/auth/google')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.data.authURL) {
|
||||
// Redirect to Google OAuth
|
||||
window.location.href = data.data.authURL
|
||||
} else {
|
||||
console.error('Failed to get Google auth URL:', data.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Google sign-in error:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={disabled || isLoading}
|
||||
className={className}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
{isLoading ? 'Signing in...' : 'Continue with Google'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
175
components/auth/LoginForm.tsx
Normal file
175
components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { GoogleSignInButton } from './GoogleSignInButton'
|
||||
import { GitHubSignInButton } from './GitHubSignInButton'
|
||||
import { Mail, Lock, Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const LoginSchema = z.object({
|
||||
emailOrId: z.string().min(1, 'Email or Silicon ID is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
rememberMe: z.boolean().optional(),
|
||||
})
|
||||
|
||||
type LoginFormData = z.infer<typeof LoginSchema>
|
||||
|
||||
interface LoginFormProps {
|
||||
onSwitchToRegister?: () => void
|
||||
}
|
||||
|
||||
export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const { login, loading } = useAuth()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
getValues,
|
||||
trigger,
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(LoginSchema),
|
||||
})
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
setError(null)
|
||||
await login(data.emailOrId, data.password, data.rememberMe)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign In</CardTitle>
|
||||
<CardDescription>Enter your email and password to access your account</CardDescription>
|
||||
</CardHeader>
|
||||
<form method="post" onSubmit={handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emailOrId">Email or Silicon ID</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="emailOrId"
|
||||
type="text"
|
||||
placeholder="your@email.com or silicon123"
|
||||
className="pl-10"
|
||||
{...register('emailOrId')}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.emailOrId && <p className="text-sm text-red-600">{errors.emailOrId.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link href="/forgot-password" className="text-sm text-primary hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
className="pl-10 pr-10"
|
||||
{...register('password')}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={loading}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span className="sr-only">{showPassword ? 'Hide password' : 'Show password'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && <p className="text-sm text-red-600">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="rememberMe"
|
||||
checked={getValues('rememberMe') || false}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue('rememberMe', checked as boolean)
|
||||
trigger('rememberMe')
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="rememberMe" className="text-sm font-normal">
|
||||
Remember me
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4 pt-6">
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Signing In...' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<GoogleSignInButton disabled={loading} />
|
||||
<GitHubSignInButton disabled={loading} />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
Don't have an account?{' '}
|
||||
{onSwitchToRegister ? (
|
||||
<button
|
||||
onClick={onSwitchToRegister}
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/auth?mode=register" className="text-primary hover:underline">
|
||||
Create account
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
308
components/auth/RegisterForm.tsx
Normal file
308
components/auth/RegisterForm.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { GoogleSignInButton } from './GoogleSignInButton'
|
||||
import { GitHubSignInButton } from './GitHubSignInButton'
|
||||
import { User, Mail, Phone, Lock, Eye, EyeOff, Check, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const RegisterSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email format'),
|
||||
phone: z.string().optional(),
|
||||
password: z
|
||||
.string()
|
||||
.min(6, 'Password must be at least 6 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[0-9!@#$%^&*]/, 'Password must contain at least one number or special character'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
agreeTerms: z.boolean().refine((val) => val === true, {
|
||||
message: 'You must agree to the Terms of Service and Privacy Policy',
|
||||
}),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
type RegisterFormData = z.infer<typeof RegisterSchema>
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSwitchToLogin?: () => void
|
||||
}
|
||||
|
||||
export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const { register: registerUser, loading } = useAuth()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
getValues,
|
||||
trigger,
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(RegisterSchema),
|
||||
})
|
||||
|
||||
const watchPassword = watch('password', '')
|
||||
|
||||
// Password strength validation
|
||||
const passwordRequirements = {
|
||||
minLength: watchPassword.length >= 6,
|
||||
hasUppercase: /[A-Z]/.test(watchPassword),
|
||||
hasNumberOrSpecial: /[0-9!@#$%^&*]/.test(watchPassword),
|
||||
}
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
setError(null)
|
||||
await registerUser(data.email, data.name, data.password)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Create Account</CardTitle>
|
||||
<CardDescription>Fill in your details to create a new account</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Enter your full name"
|
||||
className="pl-10"
|
||||
{...register('name')}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
className="pl-10"
|
||||
{...register('email')}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && <p className="text-sm text-red-600">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone Number (Optional)</Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="+1 (123) 456-7890"
|
||||
className="pl-10"
|
||||
{...register('phone')}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && <p className="text-sm text-red-600">{errors.phone.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Create Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="At least 6 characters"
|
||||
className="pl-10 pr-10"
|
||||
{...register('password')}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={loading}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span className="sr-only">{showPassword ? 'Hide password' : 'Show password'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Password strength indicators */}
|
||||
{watchPassword && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{passwordRequirements.minLength ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<X className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={passwordRequirements.minLength ? 'text-green-600' : 'text-red-500'}
|
||||
>
|
||||
Minimum 6 characters
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{passwordRequirements.hasUppercase ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<X className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
passwordRequirements.hasUppercase ? 'text-green-600' : 'text-red-500'
|
||||
}
|
||||
>
|
||||
One uppercase letter
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{passwordRequirements.hasNumberOrSpecial ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<X className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
passwordRequirements.hasNumberOrSpecial ? 'text-green-600' : 'text-red-500'
|
||||
}
|
||||
>
|
||||
One number or special character
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errors.password && <p className="text-sm text-red-600">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder="Confirm your password"
|
||||
className="pl-10 pr-10"
|
||||
{...register('confirmPassword')}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
disabled={loading}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span className="sr-only">
|
||||
{showConfirmPassword ? 'Hide password' : 'Show password'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="agreeTerms"
|
||||
checked={getValues('agreeTerms') || false}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue('agreeTerms', checked as boolean)
|
||||
trigger('agreeTerms')
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="agreeTerms" className="text-sm font-normal">
|
||||
I agree to the{' '}
|
||||
<Link href="/terms" className="text-primary hover:underline">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/privacy" className="text-primary hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Label>
|
||||
</div>
|
||||
{errors.agreeTerms && <p className="text-sm text-red-600">{errors.agreeTerms.message}</p>}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Creating Account...' : 'Create Account'}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<GoogleSignInButton disabled={loading} />
|
||||
<GitHubSignInButton disabled={loading} />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
Already have an account?{' '}
|
||||
{onSwitchToLogin ? (
|
||||
<button onClick={onSwitchToLogin} className="text-primary hover:underline cursor-pointer">
|
||||
Sign in
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-primary">Sign in</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
43
components/auth/RequireAuth.tsx
Normal file
43
components/auth/RequireAuth.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface RequireAuthProps {
|
||||
children: React.ReactNode
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function RequireAuth({ children, redirectTo = '/auth' }: RequireAuthProps) {
|
||||
const { user, loading, checkAuth } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger auth check when component mounts
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect if not authenticated after loading
|
||||
if (!loading && !user) {
|
||||
// Store the current path as the return URL
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
const returnUrl = encodeURIComponent(currentPath)
|
||||
router.push(`${redirectTo}?returnUrl=${returnUrl}`)
|
||||
}
|
||||
}, [user, loading, router, redirectTo])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
261
components/balance/BalanceCard.tsx
Normal file
261
components/balance/BalanceCard.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Wallet, Plus, RefreshCw, CreditCard } from 'lucide-react'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
interface BalanceCardProps {
|
||||
showAddBalance?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BalanceCard({ showAddBalance = true, className = '' }: BalanceCardProps) {
|
||||
const { user, getBalance, addBalance, refreshBalance } = useAuth()
|
||||
const { toast } = useToast()
|
||||
const [balance, setBalance] = useState<number>(user?.balance || 0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [amount, setAmount] = useState('')
|
||||
const [currency, setCurrency] = useState<'INR' | 'USD'>('INR')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Update balance when user changes
|
||||
useEffect(() => {
|
||||
if (user?.balance !== undefined) {
|
||||
setBalance(user.balance)
|
||||
}
|
||||
}, [user?.balance])
|
||||
|
||||
const handleRefreshBalance = async () => {
|
||||
if (!user) return
|
||||
|
||||
setRefreshing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const newBalance = await getBalance()
|
||||
setBalance(newBalance)
|
||||
await refreshBalance() // Update user context
|
||||
toast({
|
||||
title: 'Balance Updated',
|
||||
description: 'Your balance has been refreshed successfully.',
|
||||
})
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to refresh balance'
|
||||
setError(errorMessage)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddBalance = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const numAmount = parseFloat(amount)
|
||||
if (!numAmount || numAmount <= 0) {
|
||||
setError('Please enter a valid amount')
|
||||
return
|
||||
}
|
||||
|
||||
const minAmount = currency === 'INR' ? 100 : 2
|
||||
const maxAmount = currency === 'INR' ? 100000 : 1500
|
||||
|
||||
if (numAmount < minAmount || numAmount > maxAmount) {
|
||||
setError(
|
||||
`Amount must be between ${currency === 'INR' ? '₹' : '$'}${minAmount} and ${currency === 'INR' ? '₹' : '$'}${maxAmount}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await addBalance(numAmount, currency)
|
||||
|
||||
if (result.success && result.paymentUrl && result.formData) {
|
||||
// Create and submit payment form
|
||||
const form = document.createElement('form')
|
||||
form.method = 'POST'
|
||||
form.action = result.paymentUrl
|
||||
form.style.display = 'none'
|
||||
|
||||
// Add form data
|
||||
Object.entries(result.formData).forEach(([key, value]) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = key
|
||||
input.value = String(value)
|
||||
form.appendChild(input)
|
||||
})
|
||||
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
|
||||
toast({
|
||||
title: 'Redirecting to Payment',
|
||||
description: 'You will be redirected to the payment gateway.',
|
||||
})
|
||||
} else {
|
||||
setError(result.error || 'Failed to initiate payment')
|
||||
toast({
|
||||
title: 'Payment Error',
|
||||
description: result.error || 'Failed to initiate payment',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to add balance'
|
||||
setError(errorMessage)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatBalance = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`w-full max-w-md ${className}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wallet className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg font-medium">Balance</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshBalance}
|
||||
disabled={refreshing}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary">{formatBalance(balance)}</div>
|
||||
<CardDescription>Available Balance</CardDescription>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showAddBalance && (
|
||||
<div className="space-y-3">
|
||||
{!showAddForm ? (
|
||||
<Button onClick={() => setShowAddForm(true)} className="w-full" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Balance
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleAddBalance} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="amount">Amount</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
placeholder="Enter amount"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
min={currency === 'INR' ? 100 : 2}
|
||||
max={currency === 'INR' ? 100000 : 1500}
|
||||
step={currency === 'INR' ? 1 : 0.01}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Select
|
||||
value={currency}
|
||||
onValueChange={(value: 'INR' | 'USD') => setCurrency(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="INR">INR (₹)</SelectItem>
|
||||
<SelectItem value="USD">USD ($)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Min: {currency === 'INR' ? '₹100' : '$2'} | Max:{' '}
|
||||
{currency === 'INR' ? '₹1,00,000' : '$1,500'}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="h-4 w-4 mr-2" />
|
||||
Pay Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowAddForm(false)
|
||||
setAmount('')
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
61
components/balance/BalanceDisplay.tsx
Normal file
61
components/balance/BalanceDisplay.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Wallet } from 'lucide-react'
|
||||
|
||||
interface BalanceDisplayProps {
|
||||
variant?: 'default' | 'compact' | 'badge'
|
||||
showIcon?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BalanceDisplay({
|
||||
variant = 'default',
|
||||
showIcon = true,
|
||||
className = '',
|
||||
}: BalanceDisplayProps) {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const balance = user.balance || 0
|
||||
|
||||
const formatBalance = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
if (variant === 'badge') {
|
||||
return (
|
||||
<Badge variant="secondary" className={`${className}`}>
|
||||
{showIcon && <Wallet className="h-3 w-3 mr-1" />}
|
||||
{formatBalance(balance)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div className={`flex items-center space-x-1 text-sm ${className}`}>
|
||||
{showIcon && <Wallet className="h-4 w-4 text-muted-foreground" />}
|
||||
<span className="font-medium">{formatBalance(balance)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-2 ${className}`}>
|
||||
{showIcon && <Wallet className="h-5 w-5 text-muted-foreground" />}
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{formatBalance(balance)}</div>
|
||||
{/* <div className="text-xs text-muted-foreground">Balance</div> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
278
components/balance/TransactionHistory.tsx
Normal file
278
components/balance/TransactionHistory.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
History,
|
||||
RefreshCw,
|
||||
ArrowUpCircle,
|
||||
ArrowDownCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/balance-service'
|
||||
|
||||
interface Transaction {
|
||||
_id: string
|
||||
transactionId: string
|
||||
type: 'debit' | 'credit'
|
||||
amount: number
|
||||
service: string
|
||||
serviceId?: string
|
||||
description?: string
|
||||
status: 'pending' | 'completed' | 'failed'
|
||||
previousBalance: number
|
||||
newBalance: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface TransactionHistoryProps {
|
||||
className?: string
|
||||
showFilters?: boolean
|
||||
maxHeight?: string
|
||||
}
|
||||
|
||||
export function TransactionHistory({
|
||||
className = '',
|
||||
showFilters = true,
|
||||
maxHeight = '400px',
|
||||
}: TransactionHistoryProps) {
|
||||
const { user } = useAuth()
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'debit' | 'credit'>('all')
|
||||
const [serviceFilter, setServiceFilter] = useState('all')
|
||||
|
||||
const fetchTransactions = async (pageNum: number = 1, reset: boolean = false) => {
|
||||
if (!user) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: pageNum.toString(),
|
||||
limit: '10',
|
||||
type: typeFilter,
|
||||
})
|
||||
|
||||
if (serviceFilter && serviceFilter !== 'all') {
|
||||
params.append('service', serviceFilter)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/transactions?${params}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || 'Failed to fetch transactions')
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
if (reset) {
|
||||
setTransactions(data.data.transactions)
|
||||
} else {
|
||||
setTransactions((prev) => [...prev, ...data.data.transactions])
|
||||
}
|
||||
setTotalPages(data.data.pagination.totalPages)
|
||||
setPage(pageNum)
|
||||
} else {
|
||||
throw new Error(data.error?.message || 'Failed to fetch transactions')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch transactions'
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransactions(1, true)
|
||||
}, [user, typeFilter, serviceFilter])
|
||||
|
||||
const handleRefresh = () => {
|
||||
setPage(1)
|
||||
fetchTransactions(1, true)
|
||||
}
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (page < totalPages) {
|
||||
fetchTransactions(page + 1, false)
|
||||
}
|
||||
}
|
||||
|
||||
const getTransactionIcon = (type: string) => {
|
||||
return type === 'credit' ? (
|
||||
<ArrowUpCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<ArrowDownCircle className="h-4 w-4 text-red-600" />
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants = {
|
||||
completed: 'default',
|
||||
pending: 'secondary',
|
||||
failed: 'destructive',
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status as keyof typeof variants] || 'secondary'}>{status}</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-IN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<History className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">Transaction History</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
{showFilters && (
|
||||
<CardContent className="pb-4">
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onValueChange={(value: 'all' | 'debit' | 'credit') => setTypeFilter(value)}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="debit">Debits</SelectItem>
|
||||
<SelectItem value="credit">Credits</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Services" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Services</SelectItem>
|
||||
<SelectItem value="VPS">VPS Server</SelectItem>
|
||||
<SelectItem value="Kubernetes">Kubernetes</SelectItem>
|
||||
<SelectItem value="Balance">Balance Top-up</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 overflow-y-auto" style={{ maxHeight }}>
|
||||
{transactions.length === 0 && !loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<History className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No transactions found</p>
|
||||
<p className="text-sm">Your transaction history will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
transactions.map((transaction) => (
|
||||
<div
|
||||
key={transaction._id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getTransactionIcon(transaction.type)}
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{transaction.description || transaction.service}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(transaction.createdAt)}
|
||||
</div>
|
||||
{transaction.serviceId && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
ID: {transaction.serviceId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={`font-medium ${
|
||||
transaction.type === 'credit' ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{transaction.type === 'credit' ? '+' : '-'}
|
||||
{formatCurrency(transaction.amount)}
|
||||
</div>
|
||||
{/* <div className="text-xs text-muted-foreground">Balance: {formatCurrency(transaction.newBalance)}</div> */}
|
||||
<div className="mt-1">{getStatusBadge(transaction.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-4">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Loading transactions...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{page < totalPages && !loading && (
|
||||
<div className="text-center pt-4">
|
||||
<Button variant="outline" onClick={handleLoadMore} disabled={loading}>
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
3
components/balance/index.ts
Normal file
3
components/balance/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { BalanceCard } from './BalanceCard'
|
||||
export { BalanceDisplay } from './BalanceDisplay'
|
||||
export { TransactionHistory } from './TransactionHistory'
|
||||
308
components/billing/BillingHistory.tsx
Normal file
308
components/billing/BillingHistory.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from '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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { formatCurrency } from '@/lib/balance-service'
|
||||
|
||||
interface BillingRecord {
|
||||
billing_id: string
|
||||
service: string
|
||||
service_type: string
|
||||
amount: number
|
||||
currency: string
|
||||
user_email: string
|
||||
silicon_id: string
|
||||
status: string
|
||||
payment_status: string
|
||||
cycle: string
|
||||
service_name?: string
|
||||
total_amount: number
|
||||
remarks?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface BillingStats {
|
||||
totalSpent: number
|
||||
activeServices: number
|
||||
totalServices: number
|
||||
serviceBreakdown: Record<string, { count: number; amount: number }>
|
||||
}
|
||||
|
||||
interface BillingHistoryProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
active: 'bg-green-100 text-green-800',
|
||||
completed: 'bg-blue-100 text-blue-800',
|
||||
cancelled: 'bg-gray-100 text-gray-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
refunded: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
const paymentStatusColors = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
paid: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
refunded: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
const serviceTypeLabels = {
|
||||
vps: 'VPS Server',
|
||||
kubernetes: 'Kubernetes',
|
||||
developer_hire: 'Developer Hire',
|
||||
vpn: 'VPN Service',
|
||||
hosting: 'Web Hosting',
|
||||
storage: 'Cloud Storage',
|
||||
database: 'Database',
|
||||
ai_service: 'AI Service',
|
||||
custom: 'Custom Service',
|
||||
}
|
||||
|
||||
export default function BillingHistory({ className }: BillingHistoryProps) {
|
||||
const [billings, setBillings] = useState<BillingRecord[]>([])
|
||||
const [stats, setStats] = useState<BillingStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<{
|
||||
serviceType?: string
|
||||
status?: string
|
||||
}>({})
|
||||
const [pagination, setPagination] = useState({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
total: 0,
|
||||
})
|
||||
const { toast } = useToast()
|
||||
|
||||
const fetchBillingData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
limit: pagination.limit.toString(),
|
||||
offset: pagination.offset.toString(),
|
||||
})
|
||||
|
||||
if (filter.serviceType) params.append('serviceType', filter.serviceType)
|
||||
if (filter.status) params.append('status', filter.status)
|
||||
|
||||
const response = await fetch(`/api/billing?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setBillings(data.data.billings)
|
||||
setStats(data.data.stats)
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: data.data.pagination.total,
|
||||
}))
|
||||
} else {
|
||||
throw new Error(data.error?.message || 'Failed to fetch billing data')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching billing data:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load billing history',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchBillingData()
|
||||
}, [filter, pagination.limit, pagination.offset])
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
[key]: value === 'all' ? undefined : value,
|
||||
}))
|
||||
setPagination((prev) => ({ ...prev, offset: 0 }))
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
offset: prev.offset + prev.limit,
|
||||
}))
|
||||
}
|
||||
|
||||
if (loading && billings.length === 0) {
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* Statistics Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Spent</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatCurrency(stats.totalSpent)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Services</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.activeServices}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Services</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalServices}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Service Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{Object.keys(stats.serviceBreakdown).length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing History</CardTitle>
|
||||
<CardDescription>View and manage your service billing records</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<Select
|
||||
value={filter.serviceType || 'all'}
|
||||
onValueChange={(value) => handleFilterChange('serviceType', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="Filter by service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Services</SelectItem>
|
||||
{Object.entries(serviceTypeLabels).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.status || 'all'}
|
||||
onValueChange={(value) => handleFilterChange('status', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
<SelectItem value="refunded">Refunded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Billing Records */}
|
||||
<div className="space-y-4">
|
||||
{billings.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">No billing records found</div>
|
||||
) : (
|
||||
billings.map((billing) => (
|
||||
<Card key={billing.billing_id} className="border-l-4 border-l-blue-500">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-semibold">{billing.service}</h3>
|
||||
<Badge variant="outline">
|
||||
{serviceTypeLabels[
|
||||
billing.service_type as keyof typeof serviceTypeLabels
|
||||
] || billing.service_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<div>Billing ID: {billing.billing_id}</div>
|
||||
<div>Cycle: {billing.cycle}</div>
|
||||
{billing.remarks && <div>Remarks: {billing.remarks}</div>}
|
||||
<div>Created: {new Date(billing.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:items-end gap-2">
|
||||
<div className="text-lg font-bold">
|
||||
{formatCurrency(
|
||||
billing.total_amount || billing.amount,
|
||||
billing.currency as 'INR' | 'USD'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge
|
||||
className={statusColors[billing.status as keyof typeof statusColors]}
|
||||
>
|
||||
{billing.status}
|
||||
</Badge>
|
||||
<Badge
|
||||
className={
|
||||
paymentStatusColors[
|
||||
billing.payment_status as keyof typeof paymentStatusColors
|
||||
]
|
||||
}
|
||||
>
|
||||
{billing.payment_status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{billings.length > 0 && billings.length < pagination.total && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Button onClick={loadMore} disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Load More'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
components/footer.tsx
Normal file
165
components/footer.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import Link from '@/components/ui/Link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Server, Mail, Github, Twitter, Linkedin } from 'lucide-react'
|
||||
import { Logo } from '@/components/Logo'
|
||||
|
||||
export function Footer() {
|
||||
const footerSections = [
|
||||
{
|
||||
title: 'Hosting',
|
||||
links: [
|
||||
{ name: 'VPS Hosting', href: '/services' },
|
||||
{ name: 'Kubernetes', href: '/services' },
|
||||
{ name: 'Control Panels', href: '/services' },
|
||||
{ name: 'VPN Services', href: '/services' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Developer Services',
|
||||
links: [
|
||||
{ name: 'SMTP Services', href: '/services' },
|
||||
{ name: 'Database Hosting', href: '/services' },
|
||||
{ name: 'API Services', href: '/services' },
|
||||
{ name: 'Consulting', href: '/services' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Web Tools',
|
||||
links: [
|
||||
{ name: 'Speech-to-Text', href: '/tools' },
|
||||
{ name: 'Image Resize', href: '/tools' },
|
||||
{ name: 'Text-to-Speech', href: '/tools' },
|
||||
{ name: 'API Access', href: '/tools' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ name: 'About Us', href: '/about' },
|
||||
{ name: 'Contact', href: '/contact' },
|
||||
{ name: 'Legal Agreement', href: '/legal-agreement' },
|
||||
{ name: 'Privacy Policy', href: '/privacy' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: Twitter, href: '#', label: 'Twitter' },
|
||||
{ icon: Github, href: '#', label: 'GitHub' },
|
||||
{ icon: Linkedin, href: '#', label: 'LinkedIn' },
|
||||
]
|
||||
|
||||
return (
|
||||
<footer className="bg-card border-t border-border">
|
||||
{/* Newsletter Section */}
|
||||
{/* <div className="border-b border-border">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h3 className="text-2xl font-bold mb-4">Stay Updated</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Get the latest updates on new features, hosting tips, and developer resources.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 max-w-md mx-auto">
|
||||
<Input type="email" placeholder="Enter your email" className="flex-1" />
|
||||
<Button variant="hero">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Main Footer */}
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-6 gap-8">
|
||||
{/* Brand */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center space-x-2 mb-6">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center">
|
||||
<Logo size={32} />
|
||||
</div>
|
||||
<span className="text-xl font-bold font-heading tracking-tight">SiliconPin</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
Professional web hosting and developer services platform. Built by developers, for
|
||||
developers.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
{socialLinks.map((social, index) => {
|
||||
const Icon = social.icon
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-primary hover:text-primary-foreground"
|
||||
aria-label={social.label}
|
||||
asChild
|
||||
>
|
||||
<Link href={social.href}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
{footerSections.map((section, index) => (
|
||||
<div key={index}>
|
||||
<h4 className="font-semibold mb-4">{section.title}</h4>
|
||||
<ul className="space-y-3">
|
||||
{section.links.map((link, linkIndex) => (
|
||||
<li key={linkIndex}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground hover:text-primary transition-colors duration-300"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-border">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} SiliconPin. All rights reserved.
|
||||
</div>
|
||||
<div className="flex space-x-6 text-sm">
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link
|
||||
href="/refund-policy"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
Refund Policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
241
components/header.tsx
Normal file
241
components/header.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from '@/components/ui/Link'
|
||||
import {
|
||||
Menu,
|
||||
X,
|
||||
Server,
|
||||
Zap,
|
||||
Users,
|
||||
LogOut,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
Search,
|
||||
Edit,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Logo } from '@/components/Logo'
|
||||
|
||||
export function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
const { user, logout, loading } = useAuth()
|
||||
|
||||
// Ensure we only render auth-dependent content after hydration
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Home', href: '/', icon: Server },
|
||||
{ name: 'Services', href: '/services', icon: Zap },
|
||||
{ name: 'Topics', href: '/topics', icon: BookOpen },
|
||||
{ name: 'Tools', href: '/tools', icon: Users },
|
||||
{ name: 'About', href: '/about', icon: Users },
|
||||
{ name: 'Feedback', href: '/feedback', icon: MessageSquare },
|
||||
]
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-gradient-glass backdrop-blur-lg border-b border-border/50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Logo size={32} />
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-foreground font-heading tracking-tight"
|
||||
>
|
||||
SiliconPin
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="text-muted-foreground hover:text-primary transition-colors duration-300 font-medium tracking-tight"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Theme Toggle */}
|
||||
<div className="hidden md:flex">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Auth Section */}
|
||||
{!isClient || loading ? (
|
||||
// Show loading state during SSR and initial load
|
||||
<div className="hidden md:flex items-center space-x-2">
|
||||
<div className="w-16 h-9 bg-muted animate-pulse rounded"></div>
|
||||
<div className="w-20 h-9 bg-muted animate-pulse rounded"></div>
|
||||
</div>
|
||||
) : user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center gap-2">
|
||||
{user.avatar ? (
|
||||
<Image
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{user.name}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/topics/new" className="flex items-center gap-2">
|
||||
<Edit className="h-4 w-4" />
|
||||
Create Topic
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/topics" className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Search Topics
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => logout()} className="text-red-600">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center space-x-2">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/auth">Login</Link>
|
||||
</Button>
|
||||
<Button variant="hero" size="sm" asChild>
|
||||
<Link href="/auth">Get Started</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
{isMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-border/50">
|
||||
<nav className="flex flex-col space-y-4">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="flex items-center space-x-3 text-muted-foreground hover:text-primary transition-colors duration-300 p-2 rounded-lg hover:bg-accent/50"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<div className="pt-4 border-t border-border/50 flex flex-col space-y-2">
|
||||
{!isClient || loading ? (
|
||||
// Show loading state during SSR and initial load
|
||||
<>
|
||||
<div className="w-full h-9 bg-muted animate-pulse rounded"></div>
|
||||
<div className="w-full h-9 bg-muted animate-pulse rounded"></div>
|
||||
</>
|
||||
) : user ? (
|
||||
<>
|
||||
<Button variant="ghost" className="justify-start" asChild>
|
||||
<Link href="/profile">Profile</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start" asChild>
|
||||
<Link href="/dashboard">Dashboard</Link>
|
||||
</Button>
|
||||
<div className="border-t border-border/50 pt-2 mt-2">
|
||||
<Button variant="ghost" className="justify-start" asChild>
|
||||
<Link href="/topics/new" className="flex items-center gap-2">
|
||||
<Edit className="h-4 w-4" />
|
||||
Create Topic
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start" asChild>
|
||||
<Link href="/topics" className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Search Topics
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start text-red-600"
|
||||
onClick={() => logout()}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" className="justify-start" asChild>
|
||||
<Link href="/auth">Login</Link>
|
||||
</Button>
|
||||
<Button variant="hero" size="sm" asChild>
|
||||
<Link href="/auth">Get Started</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
189
components/profile/ProfileCard.tsx
Normal file
189
components/profile/ProfileCard.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useProfileData } from '@/hooks/useProfileData'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Camera, Mail, Calendar, Shield, Copy, Check, Server, Wallet } from 'lucide-react'
|
||||
import { BalanceDisplay } from '@/components/balance'
|
||||
|
||||
export function ProfileCard() {
|
||||
const { user } = useAuth()
|
||||
const { profileStats, isLoading } = useProfileData()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
// Generate Silicon ID (dummy implementation for UI testing)
|
||||
const siliconId = user.siliconId || ''
|
||||
// const siliconId = user.email?.split('@')[0]?.toUpperCase() || 'SILICON001'
|
||||
|
||||
const handleCopySiliconId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(siliconId)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const getProviderBadge = (provider: string) => {
|
||||
const variants = {
|
||||
local: { variant: 'secondary' as const, label: 'Email' },
|
||||
google: { variant: 'default' as const, label: 'Google' },
|
||||
github: { variant: 'outline' as const, label: 'GitHub' },
|
||||
}
|
||||
|
||||
const config = variants[provider as keyof typeof variants] || variants.local
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="relative mx-auto">
|
||||
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-2xl font-bold mb-4 mx-auto">
|
||||
{user.avatar ? (
|
||||
<Image
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-24 h-24 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
user.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="absolute bottom-0 right-0 rounded-full w-8 h-8 p-0"
|
||||
>
|
||||
<Camera className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">{user.name}</h2>
|
||||
|
||||
<div className="flex items-center justify-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Silicon ID:</span>
|
||||
<code className="px-2 py-1 bg-muted rounded text-sm font-mono">{siliconId}</code>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={handleCopySiliconId}>
|
||||
{copied ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{getProviderBadge(user.provider)}
|
||||
{user.role === 'admin' && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-3 text-sm">
|
||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 text-sm">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground">Joined {formatDate(user.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<Server className="w-4 h-4 text-muted-foreground" />
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-6 w-4" />
|
||||
) : (
|
||||
<span className="text-lg font-semibold">{profileStats?.activeServices || 3}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Active Services</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
{/* <Wallet className="w-4 h-4 text-muted-foreground" /> */}
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-6 w-16" />
|
||||
) : (
|
||||
<BalanceDisplay
|
||||
className="text-lg font-semibold text-green-600"
|
||||
variant="default"
|
||||
/>
|
||||
// <span className="text-lg font-semibold text-green-600">
|
||||
// ₹
|
||||
// {profileS'tats?.balance?.toLocaleString('en-IN', { minimumFractionDigits: 2 }) ||
|
||||
// '1,250.00'}
|
||||
// </span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Balance</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-3">
|
||||
<div className="text-center">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-6 w-8 mx-auto mb-1" />
|
||||
) : (
|
||||
<div className="text-lg font-semibold text-blue-600">
|
||||
{profileStats?.totalTopics || 0}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">Total Topics</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-6 w-12 mx-auto mb-1" />
|
||||
) : (
|
||||
<div className="text-lg font-semibold text-purple-600">
|
||||
{profileStats?.totalViews
|
||||
? profileStats.totalViews >= 1000
|
||||
? `${(profileStats.totalViews / 1000).toFixed(1)}K`
|
||||
: profileStats.totalViews.toLocaleString()
|
||||
: '0'}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">Total Views</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm text-muted-foreground">Account Status</span>
|
||||
<Badge variant={user.isVerified ? 'default' : 'secondary'}>
|
||||
{user.isVerified ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
1170
components/profile/ProfileSettings.tsx
Normal file
1170
components/profile/ProfileSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
200
components/seo/SEO.tsx
Normal file
200
components/seo/SEO.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* SEO Component System
|
||||
* Provides comprehensive SEO meta tags and Open Graph support
|
||||
*/
|
||||
|
||||
import Head from 'next/head'
|
||||
|
||||
export interface SEOProps {
|
||||
title?: string
|
||||
description?: string
|
||||
canonical?: string
|
||||
openGraph?: {
|
||||
type?: 'website' | 'article' | 'profile'
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
url?: string
|
||||
siteName?: string
|
||||
}
|
||||
twitter?: {
|
||||
card?: 'summary' | 'summary_large_image' | 'app' | 'player'
|
||||
site?: string
|
||||
creator?: string
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
}
|
||||
additionalMetaTags?: Array<{
|
||||
name?: string
|
||||
property?: string
|
||||
content: string
|
||||
}>
|
||||
additionalLinkTags?: Array<{
|
||||
rel: string
|
||||
href: string
|
||||
type?: string
|
||||
sizes?: string
|
||||
}>
|
||||
noindex?: boolean
|
||||
nofollow?: boolean
|
||||
}
|
||||
|
||||
const defaultSEOConfig = {
|
||||
title: 'NextJS Boilerplate',
|
||||
description:
|
||||
'A production-ready NextJS boilerplate with TypeScript, Tailwind CSS, and authentication',
|
||||
openGraph: {
|
||||
type: 'website' as const,
|
||||
siteName: 'NextJS Boilerplate',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image' as const,
|
||||
},
|
||||
}
|
||||
|
||||
export function SEO({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
openGraph,
|
||||
twitter,
|
||||
additionalMetaTags = [],
|
||||
additionalLinkTags = [],
|
||||
noindex = false,
|
||||
nofollow = false,
|
||||
}: SEOProps) {
|
||||
const seo = {
|
||||
title: title || defaultSEOConfig.title,
|
||||
description: description || defaultSEOConfig.description,
|
||||
openGraph: {
|
||||
...defaultSEOConfig.openGraph,
|
||||
...openGraph,
|
||||
},
|
||||
twitter: {
|
||||
...defaultSEOConfig.twitter,
|
||||
...twitter,
|
||||
},
|
||||
}
|
||||
|
||||
// Create robots meta content
|
||||
const robotsContent = []
|
||||
if (noindex) robotsContent.push('noindex')
|
||||
if (nofollow) robotsContent.push('nofollow')
|
||||
if (robotsContent.length === 0) robotsContent.push('index', 'follow')
|
||||
|
||||
return (
|
||||
<Head>
|
||||
{/* Basic Meta Tags */}
|
||||
<title>{seo.title}</title>
|
||||
<meta name="description" content={seo.description} />
|
||||
<meta name="robots" content={robotsContent.join(', ')} />
|
||||
|
||||
{/* Canonical URL */}
|
||||
{canonical && <link rel="canonical" href={canonical} />}
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:type" content={seo.openGraph.type} />
|
||||
<meta property="og:title" content={seo.openGraph.title || seo.title} />
|
||||
<meta property="og:description" content={seo.openGraph.description || seo.description} />
|
||||
{seo.openGraph.image && <meta property="og:image" content={seo.openGraph.image} />}
|
||||
{seo.openGraph.url && <meta property="og:url" content={seo.openGraph.url} />}
|
||||
{seo.openGraph.siteName && <meta property="og:site_name" content={seo.openGraph.siteName} />}
|
||||
|
||||
{/* Twitter Card */}
|
||||
<meta name="twitter:card" content={seo.twitter.card} />
|
||||
{seo.twitter.site && <meta name="twitter:site" content={seo.twitter.site} />}
|
||||
{seo.twitter.creator && <meta name="twitter:creator" content={seo.twitter.creator} />}
|
||||
<meta name="twitter:title" content={seo.twitter.title || seo.title} />
|
||||
<meta name="twitter:description" content={seo.twitter.description || seo.description} />
|
||||
{seo.twitter.image && <meta name="twitter:image" content={seo.twitter.image} />}
|
||||
|
||||
{/* Additional Meta Tags */}
|
||||
{additionalMetaTags.map((tag, index) => (
|
||||
<meta
|
||||
key={index}
|
||||
{...(tag.name ? { name: tag.name } : { property: tag.property })}
|
||||
content={tag.content}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Additional Link Tags */}
|
||||
{additionalLinkTags.map((tag, index) => (
|
||||
<link key={index} {...tag} />
|
||||
))}
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Article SEO component for topic posts
|
||||
*/
|
||||
interface ArticleSEOProps extends Omit<SEOProps, 'openGraph'> {
|
||||
article: {
|
||||
publishedTime?: string
|
||||
modifiedTime?: string
|
||||
author?: string
|
||||
section?: string
|
||||
tags?: string[]
|
||||
}
|
||||
openGraph?: SEOProps['openGraph']
|
||||
}
|
||||
|
||||
export function ArticleSEO({ article, openGraph, ...props }: ArticleSEOProps) {
|
||||
const articleOpenGraph = {
|
||||
...openGraph,
|
||||
type: 'article' as const,
|
||||
}
|
||||
|
||||
const additionalMetaTags = [
|
||||
...(props.additionalMetaTags || []),
|
||||
...(article.publishedTime
|
||||
? [
|
||||
{
|
||||
property: 'article:published_time',
|
||||
content: article.publishedTime,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(article.modifiedTime
|
||||
? [
|
||||
{
|
||||
property: 'article:modified_time',
|
||||
content: article.modifiedTime,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(article.author
|
||||
? [
|
||||
{
|
||||
property: 'article:author',
|
||||
content: article.author,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(article.section
|
||||
? [
|
||||
{
|
||||
property: 'article:section',
|
||||
content: article.section,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(article.tags
|
||||
? article.tags.map((tag) => ({
|
||||
property: 'article:tag',
|
||||
content: tag,
|
||||
}))
|
||||
: []),
|
||||
]
|
||||
|
||||
return <SEO {...props} openGraph={articleOpenGraph} additionalMetaTags={additionalMetaTags} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to generate SEO-friendly URLs
|
||||
*/
|
||||
export function useSEOUrl(baseUrl: string, path?: string) {
|
||||
const cleanPath = path?.startsWith('/') ? path : `/${path || ''}`
|
||||
return `${baseUrl}${cleanPath}`
|
||||
}
|
||||
70
components/theme-provider.tsx
Normal file
70
components/theme-provider.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({ children, defaultTheme = 'system' }: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(defaultTheme)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Only access localStorage after component has mounted on the client
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const storedTheme = localStorage.getItem('theme') as Theme
|
||||
if (storedTheme) {
|
||||
setTheme(storedTheme)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme, mounted])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem('theme', theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
54
components/theme-toggle.tsx
Normal file
54
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<Sun className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === 'system') {
|
||||
// If system, first switch to light/dark based on current system preference
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
setTheme(isDark ? 'light' : 'dark')
|
||||
} else {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
if (theme === 'system') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
return isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />
|
||||
}
|
||||
return theme === 'light' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
|
||||
>
|
||||
{getIcon()}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
1101
components/tools/speech-to-text-client.tsx
Normal file
1101
components/tools/speech-to-text-client.tsx
Normal file
File diff suppressed because it is too large
Load Diff
302
components/tools/text-to-speech.tsx
Normal file
302
components/tools/text-to-speech.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { Volume2, Play, Square, Download, Settings, Loader2 } from 'lucide-react'
|
||||
|
||||
interface TtsState {
|
||||
isPlaying: boolean
|
||||
isProcessing: boolean
|
||||
audioBlob: Blob | null
|
||||
audioUrl: string | null
|
||||
}
|
||||
|
||||
interface VoiceOption {
|
||||
id: string
|
||||
name: string
|
||||
language: string
|
||||
}
|
||||
|
||||
const VOICE_OPTIONS: VoiceOption[] = [
|
||||
{ id: 'en-US', name: 'English (US)', language: 'en-US' },
|
||||
{ id: 'en-GB', name: 'English (UK)', language: 'en-GB' },
|
||||
{ id: 'es-ES', name: 'Spanish', language: 'es-ES' },
|
||||
{ id: 'fr-FR', name: 'French', language: 'fr-FR' },
|
||||
{ id: 'de-DE', name: 'German', language: 'de-DE' },
|
||||
]
|
||||
|
||||
const TTS_ENDPOINT = 'https://tts41-nhdtuisbdhcvdth.siliconpin.com/tts'
|
||||
|
||||
export function TextToSpeechClient() {
|
||||
const { toast } = useToast()
|
||||
const [ttsState, setTtsState] = useState<TtsState>({
|
||||
isPlaying: false,
|
||||
isProcessing: false,
|
||||
audioBlob: null,
|
||||
audioUrl: null,
|
||||
})
|
||||
const [text, setText] = useState('')
|
||||
const [selectedVoice, setSelectedVoice] = useState('en-US')
|
||||
const [speechRate, setSpeechRate] = useState(1.0)
|
||||
const [pitch, setPitch] = useState(1.0)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsState.audioUrl) {
|
||||
URL.revokeObjectURL(ttsState.audioUrl)
|
||||
}
|
||||
}
|
||||
}, [ttsState.audioUrl])
|
||||
|
||||
const convertTextToSpeech = async () => {
|
||||
if (!text.trim()) {
|
||||
toast({
|
||||
title: 'No Text',
|
||||
description: 'Please enter some text to convert to speech',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setTtsState((prev) => ({ ...prev, isProcessing: true }))
|
||||
|
||||
try {
|
||||
const response = await fetch(TTS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: text.trim(),
|
||||
voice: selectedVoice,
|
||||
rate: speechRate,
|
||||
pitch: pitch,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`TTS API Error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
const audioBlob = await response.blob()
|
||||
const audioUrl = URL.createObjectURL(audioBlob)
|
||||
|
||||
setTtsState({
|
||||
isPlaying: false,
|
||||
isProcessing: false,
|
||||
audioBlob,
|
||||
audioUrl,
|
||||
})
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Text converted to speech successfully!',
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'TTS conversion failed'
|
||||
toast({
|
||||
title: 'Conversion Failed',
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setTtsState((prev) => ({ ...prev, isProcessing: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const playAudio = () => {
|
||||
if (ttsState.audioUrl) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
|
||||
const audio = new Audio(ttsState.audioUrl)
|
||||
audioRef.current = audio
|
||||
|
||||
audio.play()
|
||||
setTtsState((prev) => ({ ...prev, isPlaying: true }))
|
||||
|
||||
audio.onended = () => {
|
||||
setTtsState((prev) => ({ ...prev, isPlaying: false }))
|
||||
}
|
||||
|
||||
audio.onerror = () => {
|
||||
toast({
|
||||
title: 'Playback Error',
|
||||
description: 'Failed to play audio',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setTtsState((prev) => ({ ...prev, isPlaying: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stopAudio = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setTtsState((prev) => ({ ...prev, isPlaying: false }))
|
||||
}
|
||||
|
||||
const downloadAudio = () => {
|
||||
if (ttsState.audioBlob) {
|
||||
const url = URL.createObjectURL(ttsState.audioBlob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `speech-${Date.now()}.wav`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
const hasAudio = ttsState.audioBlob !== null
|
||||
const canConvert = text.trim().length > 0 && !ttsState.isProcessing
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">Text to Speech Converter</h1>
|
||||
<p className="text-muted-foreground">Convert text to natural sounding speech</p>
|
||||
</div>
|
||||
|
||||
{/* Text Input Section */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Enter Text</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Enter text to convert to speech..."
|
||||
className="min-h-32 resize-none"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm text-muted-foreground">{text.length} characters</span>
|
||||
<Button onClick={convertTextToSpeech} disabled={!canConvert}>
|
||||
{ttsState.isProcessing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Volume2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{ttsState.isProcessing ? 'Processing...' : 'Convert to Speech'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voice Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="w-5 h-5 mr-2" />
|
||||
Voice Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="voice">Voice</Label>
|
||||
<Select value={selectedVoice} onValueChange={setSelectedVoice}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VOICE_OPTIONS.map((voice) => (
|
||||
<SelectItem key={voice.id} value={voice.id}>
|
||||
{voice.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate">Speech Rate: {speechRate.toFixed(1)}x</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">0.5x</span>
|
||||
<input
|
||||
id="rate"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={speechRate}
|
||||
onChange={(e) => setSpeechRate(parseFloat(e.target.value))}
|
||||
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pitch">Pitch: {pitch.toFixed(1)}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">0.5</span>
|
||||
<input
|
||||
id="pitch"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={pitch}
|
||||
onChange={(e) => setPitch(parseFloat(e.target.value))}
|
||||
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">2.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audio Output */}
|
||||
{hasAudio && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Generated Speech</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={ttsState.isPlaying ? stopAudio : playAudio}
|
||||
variant={ttsState.isPlaying ? 'secondary' : 'default'}
|
||||
size="lg"
|
||||
>
|
||||
{ttsState.isPlaying ? (
|
||||
<>
|
||||
<Square className="w-5 h-5 mr-2" />
|
||||
Stop Playing
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
Play Audio
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button onClick={downloadAudio} variant="outline" size="lg">
|
||||
<Download className="w-5 h-5 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
276
components/topics/ImageUpload.tsx
Normal file
276
components/topics/ImageUpload.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Upload, Image as ImageIcon, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Image from 'next/image'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
interface ImageUploadProps {
|
||||
value?: string | File
|
||||
onChange: (value: File | null) => void
|
||||
className?: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
aspectRatio?: 'video' | 'square' | 'auto'
|
||||
currentImage?: string
|
||||
}
|
||||
|
||||
const ImageUpload: React.FC<ImageUploadProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
label = 'Upload Cover Image',
|
||||
disabled = false,
|
||||
aspectRatio = 'video',
|
||||
}) => {
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
// Create preview URL when value changes
|
||||
React.useEffect(() => {
|
||||
if (value instanceof File) {
|
||||
const url = URL.createObjectURL(value)
|
||||
setPreviewUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
} else if (typeof value === 'string') {
|
||||
setPreviewUrl(value)
|
||||
} else {
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const validateFile = React.useCallback(
|
||||
(file: File): boolean => {
|
||||
// Check file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select an image file (JPEG, PNG, GIF, WebP)',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Check file size (5MB limit)
|
||||
const maxSize = 5 * 1024 * 1024 // 5MB
|
||||
if (file.size > maxSize) {
|
||||
toast({
|
||||
title: 'File too large',
|
||||
description: 'Please select an image smaller than 5MB',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[toast]
|
||||
)
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!validateFile(file)) return
|
||||
|
||||
try {
|
||||
setIsUploading(true)
|
||||
onChange(file)
|
||||
toast({
|
||||
title: 'Image selected',
|
||||
description: 'Cover image has been updated',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to handle file:', error)
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: 'Failed to process the image',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
},
|
||||
[onChange, toast, validateFile]
|
||||
)
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
await handleFile(file)
|
||||
|
||||
// Clear the input value to allow uploading the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onChange(null)
|
||||
setPreviewUrl(null)
|
||||
toast({
|
||||
title: 'Image removed',
|
||||
description: 'Cover image has been removed',
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (disabled) return
|
||||
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
const file = files[0]
|
||||
|
||||
if (file) {
|
||||
await handleFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const getAspectRatioClass = () => {
|
||||
switch (aspectRatio) {
|
||||
case 'video':
|
||||
return 'aspect-video'
|
||||
case 'square':
|
||||
return 'aspect-square'
|
||||
default:
|
||||
return 'min-h-[200px]'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{previewUrl ? (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-lg bg-muted border-2 border-dashed border-transparent',
|
||||
getAspectRatioClass()
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt="Cover image preview"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 hover:bg-black/10 transition-colors">
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
disabled={disabled || isUploading}
|
||||
className="bg-white/90 hover:bg-white"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
disabled={disabled || isUploading}
|
||||
className="bg-red-500/90 hover:bg-red-500"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-all duration-200',
|
||||
getAspectRatioClass(),
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5 scale-[1.02]'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick(e as unknown as React.MouseEvent)
|
||||
}
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={`${label} - click to browse or drag and drop`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<p className="text-sm font-medium">Uploading image...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
{isDragging ? (
|
||||
<Upload className="w-6 h-6 text-primary" />
|
||||
) : (
|
||||
<ImageIcon className="w-6 h-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-foreground">{label}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isDragging ? 'Drop your image here' : 'Click to browse or drag and drop'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Supports JPEG, PNG, GIF, WebP (max 5MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageUpload
|
||||
273
components/topics/SearchableTagSelect.tsx
Normal file
273
components/topics/SearchableTagSelect.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Check, ChevronsUpDown, Plus, X, Tag as TagIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
export interface Tag {
|
||||
id?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface SearchableTagSelectProps {
|
||||
availableTags: Tag[]
|
||||
selectedTags: Tag[]
|
||||
onChange: (tags: Tag[]) => void
|
||||
onCreateTag?: (tagName: string) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
maxTags?: number
|
||||
}
|
||||
|
||||
const SearchableTagSelect: React.FC<SearchableTagSelectProps> = ({
|
||||
availableTags,
|
||||
selectedTags,
|
||||
onChange,
|
||||
onCreateTag,
|
||||
className,
|
||||
placeholder = "Add tags...",
|
||||
disabled = false,
|
||||
maxTags,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const { toast } = useToast()
|
||||
|
||||
// Filter tags that are not already selected and match the search input
|
||||
const filteredTags = availableTags.filter(
|
||||
(tag) =>
|
||||
!selectedTags.some((selectedTag) =>
|
||||
selectedTag.name.toLowerCase() === tag.name.toLowerCase()
|
||||
) &&
|
||||
tag.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
|
||||
// Handle tag selection
|
||||
const selectTag = (tag: Tag) => {
|
||||
if (maxTags && selectedTags.length >= maxTags) {
|
||||
toast({
|
||||
title: 'Tag limit reached',
|
||||
description: `You can only add up to ${maxTags} tags`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onChange([...selectedTags, tag])
|
||||
setInputValue('')
|
||||
setOpen(false)
|
||||
|
||||
toast({
|
||||
title: 'Tag added',
|
||||
description: `"${tag.name}" has been added to your tags`,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle tag removal
|
||||
const removeTag = (tagToRemove: Tag) => {
|
||||
onChange(selectedTags.filter((tag) => tag.name !== tagToRemove.name))
|
||||
toast({
|
||||
title: 'Tag removed',
|
||||
description: `"${tagToRemove.name}" has been removed`,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle creating a new tag
|
||||
const handleCreateTag = () => {
|
||||
const trimmedInput = inputValue.trim()
|
||||
|
||||
if (!trimmedInput) return
|
||||
|
||||
// Check if tag already exists (case-insensitive)
|
||||
const existsInAvailable = availableTags.some(
|
||||
(tag) => tag.name.toLowerCase() === trimmedInput.toLowerCase()
|
||||
)
|
||||
const existsInSelected = selectedTags.some(
|
||||
(tag) => tag.name.toLowerCase() === trimmedInput.toLowerCase()
|
||||
)
|
||||
|
||||
if (existsInSelected) {
|
||||
toast({
|
||||
title: 'Tag already selected',
|
||||
description: `"${trimmedInput}" is already in your selected tags`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (maxTags && selectedTags.length >= maxTags) {
|
||||
toast({
|
||||
title: 'Tag limit reached',
|
||||
description: `You can only add up to ${maxTags} tags`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (existsInAvailable) {
|
||||
// Tag exists in available tags, just select it
|
||||
const existingTag = availableTags.find(
|
||||
(tag) => tag.name.toLowerCase() === trimmedInput.toLowerCase()
|
||||
)
|
||||
if (existingTag) {
|
||||
selectTag(existingTag)
|
||||
}
|
||||
} else {
|
||||
// Create new tag
|
||||
const newTag: Tag = {
|
||||
id: `temp-${Date.now()}`,
|
||||
name: trimmedInput,
|
||||
}
|
||||
|
||||
if (onCreateTag) {
|
||||
onCreateTag(trimmedInput)
|
||||
}
|
||||
|
||||
onChange([...selectedTags, newTag])
|
||||
setInputValue('')
|
||||
setOpen(false)
|
||||
|
||||
toast({
|
||||
title: 'New tag created',
|
||||
description: `"${trimmedInput}" has been created and added`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasExactMatch = filteredTags.some(
|
||||
(tag) => tag.name.toLowerCase() === inputValue.toLowerCase()
|
||||
)
|
||||
|
||||
const canCreateNewTag = inputValue.trim() !== '' && !hasExactMatch &&
|
||||
(!maxTags || selectedTags.length < maxTags)
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{/* Selected Tags Display */}
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.map((tag, index) => (
|
||||
<Badge
|
||||
key={tag.id || `${tag.name}-${index}`}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 pr-1 pl-3 py-1 text-sm"
|
||||
>
|
||||
<TagIcon className="w-3 h-3" />
|
||||
{tag.name}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground ml-1"
|
||||
onClick={() => removeTag(tag)}
|
||||
disabled={disabled}
|
||||
aria-label={`Remove ${tag.name} tag`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Selection Dropdown */}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
disabled={disabled || (maxTags ? selectedTags.length >= maxTags : false)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="w-4 h-4" />
|
||||
{placeholder}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search or create tags..."
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="p-2">
|
||||
{canCreateNewTag ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex w-full items-center justify-start px-2 py-2 text-sm"
|
||||
onClick={handleCreateTag}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create "{inputValue.trim()}"
|
||||
</Button>
|
||||
) : inputValue.trim() === '' ? (
|
||||
<div className="text-center text-muted-foreground py-2">
|
||||
Start typing to search or create tags...
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-2">
|
||||
{maxTags && selectedTags.length >= maxTags
|
||||
? `Maximum ${maxTags} tags allowed`
|
||||
: 'No tags found'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{filteredTags.map((tag, index) => (
|
||||
<CommandItem
|
||||
key={tag.id || `${tag.name}-${index}`}
|
||||
value={tag.name}
|
||||
onSelect={() => selectTag(tag)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedTags.some((selectedTag) =>
|
||||
selectedTag.name.toLowerCase() === tag.name.toLowerCase()
|
||||
)
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<TagIcon className="mr-2 h-4 w-4" />
|
||||
{tag.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Tag counter and limit display */}
|
||||
{maxTags && (
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
{selectedTags.length} / {maxTags} tags
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchableTagSelect
|
||||
130
components/topics/SimpleShareButtons.tsx
Normal file
130
components/topics/SimpleShareButtons.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface SimpleShareButtonsProps {
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function SimpleShareButtons({ title, url }: SimpleShareButtonsProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Get full URL
|
||||
const fullUrl = typeof window !== 'undefined' ? window.location.origin + url : url
|
||||
|
||||
const handleShare = (platform: string) => {
|
||||
let shareUrl = ''
|
||||
|
||||
switch (platform) {
|
||||
case 'twitter':
|
||||
shareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(fullUrl)}`
|
||||
break
|
||||
case 'facebook':
|
||||
shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(fullUrl)}`
|
||||
break
|
||||
case 'linkedin':
|
||||
shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(fullUrl)}&title=${encodeURIComponent(title)}`
|
||||
break
|
||||
case 'whatsapp':
|
||||
shareUrl = `https://wa.me/?text=${encodeURIComponent(title)} ${encodeURIComponent(fullUrl)}`
|
||||
break
|
||||
}
|
||||
|
||||
if (shareUrl) {
|
||||
window.open(shareUrl, '_blank', 'width=600,height=400')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm font-medium text-muted-foreground">Share:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3 hover:bg-blue-50 hover:text-blue-600"
|
||||
onClick={() => handleShare('twitter')}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
Twitter
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3 hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={() => handleShare('facebook')}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
Facebook
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3 hover:bg-blue-50 hover:text-blue-800"
|
||||
onClick={() => handleShare('linkedin')}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
LinkedIn
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3 hover:bg-green-50 hover:text-green-600"
|
||||
onClick={() => handleShare('whatsapp')}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.567-.01-.197 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.890-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
|
||||
</svg>
|
||||
WhatsApp
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-8 px-3 transition-colors ${
|
||||
copied
|
||||
? 'bg-green-50 text-green-700 hover:bg-green-50 hover:text-green-700'
|
||||
: 'hover:bg-gray-50 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={handleCopyLink}
|
||||
disabled={copied}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
components/topics/TopicCard.tsx
Normal file
101
components/topics/TopicCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Calendar, Clock, User } from 'lucide-react'
|
||||
import { ITopic } from '@/models/topic'
|
||||
|
||||
interface TopicCardProps {
|
||||
topic: ITopic
|
||||
showAuthor?: boolean
|
||||
showReadingTime?: boolean
|
||||
}
|
||||
|
||||
export function TopicCard({ topic, showAuthor = true, showReadingTime = true }: TopicCardProps) {
|
||||
const publishedDate = new Date(topic.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<Link href={`/topics/${topic.slug}`} className="group block">
|
||||
<Card className="overflow-hidden transition-all duration-300 hover:shadow-lg hover:scale-[1.02]">
|
||||
<div className="aspect-video relative overflow-hidden">
|
||||
<Image
|
||||
src={topic.coverImage}
|
||||
alt={topic.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-6">
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<time dateTime={new Date(topic.publishedAt).toISOString()}>
|
||||
{publishedDate}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{showReadingTime && topic.readingTime && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{topic.readingTime.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAuthor && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
<span>{topic.author}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-bold mb-3 line-clamp-2 transition-colors group-hover:text-primary">
|
||||
{topic.title}
|
||||
</h2>
|
||||
|
||||
{/* Excerpt */}
|
||||
<p className="text-muted-foreground mb-4 line-clamp-3 leading-relaxed">
|
||||
{topic.excerpt}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topic.tags.slice(0, 3).map((tag) => (
|
||||
<Badge
|
||||
key={tag.id || tag.name}
|
||||
variant="secondary"
|
||||
className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{topic.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{topic.tags.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Featured indicator */}
|
||||
{topic.featured && (
|
||||
<div className="mt-3">
|
||||
<Badge className="text-xs bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600">
|
||||
Featured
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicCard
|
||||
7
components/topics/TopicClientComponents.tsx
Normal file
7
components/topics/TopicClientComponents.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
import { ViewTracker } from './ViewTracker'
|
||||
|
||||
// ViewTracker component - only handles view tracking
|
||||
export function TopicViewTracker({ topicSlug, isDraft }: { topicSlug: string; isDraft: boolean }) {
|
||||
return <ViewTracker topicSlug={topicSlug} enabled={!isDraft} />
|
||||
}
|
||||
53
components/topics/TopicContent.tsx
Normal file
53
components/topics/TopicContent.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import { PartialBlock } from '@blocknote/core'
|
||||
import '@blocknote/mantine/style.css'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
interface TopicContentProps {
|
||||
content: string
|
||||
contentRTE?: unknown
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TopicContent({ content, contentRTE, className = '' }: TopicContentProps) {
|
||||
// Create an editor instance with initial content
|
||||
const editor = useCreateBlockNote({
|
||||
initialContent:
|
||||
contentRTE && Array.isArray(contentRTE) && contentRTE.length > 0
|
||||
? (contentRTE as PartialBlock[])
|
||||
: ([{ type: 'paragraph', content: '' }] as PartialBlock[]),
|
||||
})
|
||||
|
||||
// If we have rich text content, use BlockNote viewer
|
||||
if (contentRTE && Array.isArray(contentRTE) && contentRTE.length > 0) {
|
||||
return (
|
||||
<div className={`topic-content ${className}`}>
|
||||
<BlockNoteView editor={editor} editable={false} theme="light" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback to rendering plain HTML content
|
||||
return (
|
||||
<div
|
||||
className={`topic-content prose prose-lg max-w-none
|
||||
prose-headings:font-bold prose-headings:text-foreground
|
||||
prose-p:text-muted-foreground prose-p:leading-relaxed
|
||||
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
|
||||
prose-strong:text-foreground prose-strong:font-semibold
|
||||
prose-code:bg-muted prose-code:px-2 prose-code:py-1 prose-code:rounded
|
||||
prose-pre:bg-muted prose-pre:border prose-pre:rounded-lg
|
||||
prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:bg-muted/50
|
||||
prose-ul:text-muted-foreground prose-ol:text-muted-foreground
|
||||
prose-li:text-muted-foreground
|
||||
prose-img:rounded-lg prose-img:shadow-lg
|
||||
${className}`}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicContent
|
||||
34
components/topics/TopicEditButton.tsx
Normal file
34
components/topics/TopicEditButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Edit3 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface TopicEditButtonProps {
|
||||
topicId: string
|
||||
authorId: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TopicEditButton({ topicId, authorId, className }: TopicEditButtonProps) {
|
||||
const { user } = useAuth()
|
||||
|
||||
// Only show edit button if user is authenticated and is the author
|
||||
if (!user || user.id !== authorId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={className}
|
||||
>
|
||||
<Link href={`/topics/edit/${topicId}`} className="flex items-center gap-2">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Edit Post
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
41
components/topics/ViewTracker.tsx
Normal file
41
components/topics/ViewTracker.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface ViewTrackerProps {
|
||||
topicSlug: string
|
||||
enabled?: boolean // Allow disabling for drafts or other cases
|
||||
}
|
||||
|
||||
export function ViewTracker({ topicSlug, enabled = true }: ViewTrackerProps) {
|
||||
const hasTracked = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || hasTracked.current || !topicSlug) {
|
||||
return
|
||||
}
|
||||
|
||||
const trackView = async () => {
|
||||
try {
|
||||
await fetch(`/api/topics/${encodeURIComponent(topicSlug)}/view`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
hasTracked.current = true
|
||||
} catch (error) {
|
||||
console.warn('Failed to track topic view:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Track view after a short delay to ensure the user actually viewed the post
|
||||
const timer = setTimeout(trackView, 2000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [topicSlug, enabled])
|
||||
|
||||
// This component doesn't render anything
|
||||
return null
|
||||
}
|
||||
22
components/ui/Link.tsx
Normal file
22
components/ui/Link.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Link from 'next/link'
|
||||
import type { LinkProps } from 'next/link'
|
||||
import { AnchorHTMLAttributes } from 'react'
|
||||
|
||||
const CustomLink = ({ href, ...rest }: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const isInternalLink = href && href.startsWith('/')
|
||||
const isAnchorLink = href && href.startsWith('#')
|
||||
|
||||
if (isInternalLink) {
|
||||
return <Link className="break-words" href={href} {...rest} />
|
||||
}
|
||||
|
||||
if (isAnchorLink) {
|
||||
return <a className="break-words" href={href} {...rest} />
|
||||
}
|
||||
|
||||
return (
|
||||
<a className="break-words" target="_blank" rel="noopener noreferrer" href={href} {...rest} />
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomLink
|
||||
52
components/ui/accordion.tsx
Normal file
52
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
|
||||
))
|
||||
AccordionItem.displayName = 'AccordionItem'
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
49
components/ui/alert.tsx
Normal file
49
components/ui/alert.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
))
|
||||
Alert.displayName = 'Alert'
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = 'AlertTitle'
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
45
components/ui/avatar.tsx
Normal file
45
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react'
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
33
components/ui/badge.tsx
Normal file
33
components/ui/badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
101
components/ui/breadcrumb.tsx
Normal file
101
components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<'nav'> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = 'Breadcrumb'
|
||||
|
||||
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<'ol'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
BreadcrumbList.displayName = 'BreadcrumbList'
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<'li'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />
|
||||
)
|
||||
)
|
||||
BreadcrumbItem.displayName = 'BreadcrumbItem'
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<'a'> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'a'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn('transition-colors hover:text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = 'BreadcrumbLink'
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<'span'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('font-normal text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
BreadcrumbPage.displayName = 'BreadcrumbPage'
|
||||
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<'li'>) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator'
|
||||
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis'
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
65
components/ui/button.tsx
Normal file
65
components/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground hover:bg-primary-hover shadow-primary hover:shadow-glow',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary-hover shadow-secondary',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
hero: 'bg-gradient-hero text-primary-foreground hover:scale-105 shadow-glow font-semibold',
|
||||
glass: 'bg-gradient-glass backdrop-blur-sm border border-border/50 hover:bg-accent/50',
|
||||
success: 'bg-success text-success-foreground hover:bg-success/90',
|
||||
premium:
|
||||
'bg-gradient-secondary text-secondary-foreground hover:scale-105 shadow-secondary font-semibold',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
xl: 'h-14 rounded-lg px-10 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
75
components/ui/card.tsx
Normal file
75
components/ui/card.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|
||||
26
components/ui/checkbox.tsx
Normal file
26
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
155
components/ui/command.tsx
Normal file
155
components/ui/command.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
39
components/ui/custom-solution-cta.tsx
Normal file
39
components/ui/custom-solution-cta.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
interface CustomSolutionCTAProps {
|
||||
title?: string
|
||||
description: string
|
||||
buttonText?: string
|
||||
buttonHref?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CustomSolutionCTA({
|
||||
title = "Need Something Custom?",
|
||||
description,
|
||||
buttonText = "Contact Us",
|
||||
buttonHref,
|
||||
className = ""
|
||||
}: CustomSolutionCTAProps) {
|
||||
const handleClick = () => {
|
||||
if (buttonHref) {
|
||||
window.location.href = buttonHref
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`text-center mt-16 p-8 bg-muted/50 rounded-lg ${className}`}>
|
||||
<h3 className="text-2xl font-semibold mb-4">{title}</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{description}
|
||||
</p>
|
||||
<Button size="lg" onClick={buttonHref ? handleClick : undefined}>
|
||||
{buttonText}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
components/ui/dialog.tsx
Normal file
129
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
228
components/ui/dropdown-menu.tsx
Normal file
228
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
152
components/ui/form.tsx
Normal file
152
components/ui/form.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>')
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? '') : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
27
components/ui/hover-card.tsx
Normal file
27
components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react'
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
21
components/ui/label.tsx
Normal file
21
components/ui/label.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
120
components/ui/navigation-menu.tsx
Normal file
120
components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react'
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative z-10 flex max-w-max flex-1 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
))
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('group flex flex-1 list-none items-center justify-center space-x-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
|
||||
)
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{' '}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
))
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn('absolute left-0 top-full flex justify-center')}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
))
|
||||
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
||||
117
components/ui/pagination.tsx
Normal file
117
components/ui/pagination.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ButtonProps, buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = 'Pagination'
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<'ul'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn('flex flex-row items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = 'PaginationContent'
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<'li'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn('', className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = 'PaginationItem'
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, 'size'> &
|
||||
React.ComponentProps<'a'>
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = 'icon',
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = 'PaginationLink'
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn('gap-1 pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = 'PaginationPrevious'
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn('gap-1 pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = 'PaginationNext'
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis'
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
29
components/ui/popover.tsx
Normal file
29
components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
23
components/ui/progress.tsx
Normal file
23
components/ui/progress.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-4 w-full overflow-hidden rounded-full bg-secondary', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
63
components/ui/radio-group.tsx
Normal file
63
components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface RadioGroupContextValue {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
name?: string
|
||||
}
|
||||
|
||||
const RadioGroupContext = React.createContext<RadioGroupContextValue>({})
|
||||
|
||||
interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
name?: string
|
||||
}
|
||||
|
||||
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
||||
({ className, value, onValueChange, name, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupContext.Provider value={{ value, onValueChange, name }}>
|
||||
<div
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
role="radiogroup"
|
||||
/>
|
||||
</RadioGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
RadioGroup.displayName = "RadioGroup"
|
||||
|
||||
interface RadioGroupItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
value: string
|
||||
}
|
||||
|
||||
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
const context = React.useContext(RadioGroupContext)
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
name={context.name}
|
||||
value={value}
|
||||
checked={context.value === value}
|
||||
onChange={() => context.onValueChange?.(value)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
RadioGroupItem.displayName = "RadioGroupItem"
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
44
components/ui/scroll-area.tsx
Normal file
44
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
170
components/ui/select.tsx
Normal file
170
components/ui/select.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: 'sm' | 'default'
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
24
components/ui/separator.tsx
Normal file
24
components/ui/separator.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
130
components/ui/sheet.tsx
Normal file
130
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
7
components/ui/skeleton.tsx
Normal file
7
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
27
components/ui/switch.tsx
Normal file
27
components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
53
components/ui/tabs.tsx
Normal file
53
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
122
components/ui/toast.tsx
Normal file
122
components/ui/toast.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as React from 'react'
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
33
components/ui/toaster.tsx
Normal file
33
components/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@/components/ui/toast'
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
28
components/ui/tooltip.tsx
Normal file
28
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user