Refactor: Improve error handling in authentication flow

This commit is contained in:
Your Name
2026-04-17 19:55:35 +00:00
parent 7c5da339cb
commit 61bbca2e5d
33 changed files with 2834 additions and 4185 deletions
+12 -16
View File
@@ -11,7 +11,7 @@ COPY package*.json ./
COPY frontend/package*.json ./frontend/
# Install dependencies
RUN npm install --legacy-peer-deps
RUN --mount=type=cache,target=/root/.npm npm install --legacy-peer-deps
# Copy frontend source
COPY frontend/ ./frontend/
@@ -25,29 +25,25 @@ ARG NEXT_PUBLIC_SOLANA_RPC_URL
WORKDIR /app/frontend
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npx next build --webpack
# Build with standalone output
RUN --mount=type=cache,target=/app/frontend/.next/cache npx next build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY frontend/package*.json ./frontend/
# Copy node_modules from builder (includes compiled native modules)
COPY --from=builder /app/node_modules ./node_modules
# Copy built files from builder
COPY --from=builder /app/frontend/.next ./frontend/.next
COPY --from=builder /app/frontend/public ./frontend/public
COPY --from=builder /app/frontend/next.config.js ./frontend/
WORKDIR /app/frontend
# Copy standalone output
COPY --from=builder /app/frontend/.next/standalone ./
COPY --from=builder /app/frontend/.next/static ./.next/static
COPY --from=builder /app/frontend/public ./public
# Expose port
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Start application
CMD ["npm", "start"]
CMD ["node", "server.js"]
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5 -9
View File
@@ -1,21 +1,17 @@
/** @type {import('next').NextConfig} */
const path = require('path');
const nextConfig = {
webpack: (config) => {
config.resolve.alias['@'] = path.join(__dirname, 'src');
config.parallelism = 1;
return config;
},
output: 'standalone',
allowedDevOrigins: ['172.20.1.238'],
async rewrites() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL}/api/:path*`,
destination: `${apiUrl}/api/:path*`,
},
{
source: '/webhooks/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL}/webhooks/:path*`,
destination: `${apiUrl}/webhooks/:path*`,
},
];
},
+3 -3
View File
@@ -30,16 +30,16 @@
"socket.io-client": "^4.7.3",
"sonner": "^1.2.4",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.4.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3"
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2"
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
'@tailwindcss/postcss': {},
},
};
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { authApi } from '@/lib/api';
import { useStore } from '@/lib/store';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function AuthCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser, setToken } = useStore();
const [status, setStatus] = useState('Completing sign in...');
useEffect(() => {
const code = searchParams.get('code');
if (!code) {
toast.error('Invalid authentication response');
router.push('/login');
return;
}
const handleCallback = async () => {
try {
const response = await authApi.plexCallback(code);
const { user, token } = response.data;
localStorage.setItem('token', token);
setUser(user);
setToken(token);
toast.success(`Welcome, ${user.plexUsername}!`);
router.push('/dashboard');
} catch (error) {
setStatus('Authentication failed');
toast.error('Authentication failed');
console.error(error);
setTimeout(() => router.push('/login'), 2000);
}
};
handleCallback();
}, [searchParams, router, setUser, setToken]);
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950 p-4">
<div className="text-center">
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg text-foreground">{status}</p>
</div>
</div>
);
}
@@ -0,0 +1,108 @@
'use client';
import { useEffect, useState } from 'react';
import { transactionApi } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { formatNumber } from '@/lib/utils';
import { Zap, TrendingUp, TrendingDown, Gift } from 'lucide-react';
interface Activity {
id: string;
type: 'EARN' | 'SPEND' | 'BONUS' | 'ADJUSTMENT';
amount: number;
contentTitle: string | null;
user: {
plexUsername: string;
};
createdAt: string;
}
export function ActivityFeed() {
const [activities, setActivities] = useState<Activity[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadActivity();
// Refresh every minute
const interval = setInterval(loadActivity, 60000);
return () => clearInterval(interval);
}, []);
const loadActivity = async () => {
try {
const response = await transactionApi.getRecentActivity();
setActivities(response.data.transactions);
} catch (error) {
console.error('Failed to load global activity:', error);
} finally {
setIsLoading(false);
}
};
const getIcon = (type: string) => {
switch (type) {
case 'EARN': return <TrendingUp className="h-3 w-3 text-green-500" />;
case 'SPEND': return <TrendingDown className="h-3 w-3 text-red-500" />;
case 'BONUS': return <Gift className="h-3 w-3 text-purple-500" />;
default: return <Zap className="h-3 w-3 text-primary" />;
}
};
if (isLoading && activities.length === 0) {
return (
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Zap className="h-4 w-4 text-primary" />
Global Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4 animate-pulse">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 bg-muted rounded-md" />
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="h-full overflow-hidden border-none bg-muted/30 shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Zap className="h-4 w-4 text-primary" />
Global Activity
</CardTitle>
</CardHeader>
<CardContent className="px-4">
<div className="space-y-3">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start gap-3 text-xs">
<div className="mt-1">
{getIcon(activity.type)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold truncate text-foreground/90">
{activity.user.plexUsername}
</p>
<p className="text-muted-foreground truncate">
{activity.type === 'EARN' ? 'earned' : activity.type === 'SPEND' ? 'spent' : 'received'} {' '}
<span className={activity.type === 'EARN' || activity.type === 'BONUS' ? 'text-green-500' : 'text-red-500'}>
{formatNumber(activity.amount)} $COOP
</span>
</p>
{activity.contentTitle && (
<p className="text-[10px] text-muted-foreground/60 truncate italic">
{activity.contentTitle}
</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,114 @@
'use client';
import { useEffect, useState } from 'react';
import { userApi } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { formatNumber, formatDuration } from '@/lib/utils';
import { Trophy, Medal, Star, Clock, Coins } from 'lucide-react';
interface LeaderboardUser {
id: string;
plexUsername: string;
totalEarned: number;
watchTimeMinutes: number;
}
export function Leaderboard() {
const [data, setData] = useState<{ topEarners: LeaderboardUser[]; topWatchers: LeaderboardUser[] }>({
topEarners: [],
topWatchers: [],
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadLeaderboard();
}, []);
const loadLeaderboard = async () => {
try {
const response = await userApi.getLeaderboard();
setData(response.data);
} catch (error) {
console.error('Failed to load leaderboard:', error);
} finally {
setIsLoading(false);
}
};
const getRankIcon = (index: number) => {
switch (index) {
case 0: return <Trophy className="h-4 w-4 text-yellow-500" />;
case 1: return <Medal className="h-4 w-4 text-slate-400" />;
case 2: return <Medal className="h-4 w-4 text-amber-600" />;
default: return <span className="w-4 text-center text-xs text-muted-foreground">{index + 1}</span>;
}
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<div className="animate-pulse space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 bg-muted rounded-md" />
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-none bg-muted/30 shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
Leaderboard
</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="earners" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-4 h-8 bg-background/50">
<TabsTrigger value="earners" className="text-xs py-1">Top Earners</TabsTrigger>
<TabsTrigger value="watchers" className="text-xs py-1">Top Watchers</TabsTrigger>
</TabsList>
<TabsContent value="earners" className="mt-0">
<div className="space-y-2">
{data.topEarners.map((user, index) => (
<div key={user.id} className="flex items-center justify-between p-2 rounded-lg bg-background/40 hover:bg-background/60 transition-colors">
<div className="flex items-center gap-3">
<div className="w-6 flex justify-center">{getRankIcon(index)}</div>
<span className="text-sm font-medium">{user.plexUsername}</span>
</div>
<div className="flex items-center gap-1 text-xs font-bold text-green-500">
<Coins className="h-3 w-3" />
{formatNumber(user.totalEarned)}
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="watchers" className="mt-0">
<div className="space-y-2">
{data.topWatchers.map((user, index) => (
<div key={user.id} className="flex items-center justify-between p-2 rounded-lg bg-background/40 hover:bg-background/60 transition-colors">
<div className="flex items-center gap-3">
<div className="w-6 flex justify-center">{getRankIcon(index)}</div>
<span className="text-sm font-medium">{user.plexUsername}</span>
</div>
<div className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
<Clock className="h-3 w-3" />
{Math.floor(user.watchTimeMinutes / 60)}h {user.watchTimeMinutes % 60}m
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
@@ -0,0 +1,120 @@
'use client';
import { useEffect, useState } from 'react';
import { userApi } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatDate } from '@/lib/utils';
import { Film, Tv, Clock, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
interface ContentRequest {
id: string;
mediaType: string;
mediaId: number;
title: string;
status: 'PENDING' | 'APPROVED' | 'PROCESSING' | 'AVAILABLE' | 'DECLINED' | 'FAILED';
creditsCost: number;
requestedAt: string;
}
export function RequestHistory() {
const [requests, setRequests] = useState<ContentRequest[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadRequests();
}, []);
const loadRequests = async () => {
try {
const response = await userApi.getRequests();
setRequests(response.data.requests);
} catch (error) {
console.error('Failed to load requests:', error);
} finally {
setIsLoading(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'AVAILABLE': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case 'DECLINED':
case 'FAILED': return <AlertCircle className="h-4 w-4 text-red-500" />;
case 'PROCESSING': return <Loader2 className="h-4 w-4 text-yellow-500 animate-spin" />;
default: return <Clock className="h-4 w-4 text-muted-foreground" />;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'AVAILABLE': return <Badge className="bg-green-500/10 text-green-500 border-green-500/20">Available</Badge>;
case 'PROCESSING': return <Badge className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">Processing</Badge>;
case 'APPROVED': return <Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20">Approved</Badge>;
case 'DECLINED': return <Badge className="bg-red-500/10 text-red-500 border-red-500/20">Declined</Badge>;
default: return <Badge variant="outline">{status}</Badge>;
}
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Loading requests...
</CardContent>
</Card>
);
}
if (requests.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No requests yet. Use your $COOP to add content to the server!
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>My Content Requests</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{requests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between p-4 rounded-lg bg-muted/50"
>
<div className="flex items-center gap-4">
<div className="p-2 rounded-full bg-primary/10 text-primary">
{request.mediaType === 'movie' ? <Film className="h-4 w-4" /> : <Tv className="h-4 w-4" />}
</div>
<div>
<p className="font-medium">{request.title}</p>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
{getStatusIcon(request.status)}
{getStatusBadge(request.status)}
</span>
<span>Requested {formatDate(request.requestedAt)}</span>
</div>
</div>
</div>
<div className="text-right">
<p className="font-bold text-primary">
{request.creditsCost} $COOP
</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
Cost
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,222 @@
'use client';
import { useState, useEffect } from 'react';
import { overseerApi } from '@/lib/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Search, Loader2, Film, Tv, Plus, CheckCircle2, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
interface SearchResult {
id: number;
mediaType: 'movie' | 'tv';
title?: string;
name?: string;
overview: string;
posterPath: string;
releaseDate?: string;
firstAirDate?: string;
mediaInfo?: {
status: number; // 1 = unknown, 2 = pending, 3 = processing, 4 = partially available, 5 = available
};
}
interface SearchRequestModalProps {
open: boolean;
onClose: () => void;
onRequested: () => void;
costs: { movie: number; tv: number };
balance: number;
}
const OVERSEER_IMAGE_BASE = 'https://image.tmdb.org/t/p/w200';
export function SearchRequestModal({ open, onClose, onRequested, costs, balance }: SearchRequestModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isRequesting, setIsRequesting] = useState<number | null>(null);
useEffect(() => {
const timer = setTimeout(() => {
if (query.length > 2) {
handleSearch();
}
}, 500);
return () => clearTimeout(timer);
}, [query]);
const handleSearch = async () => {
setIsLoading(true);
try {
const response = await overseerApi.search(query);
setResults(response.data.results || []);
} catch (error) {
console.error('Search failed:', error);
} finally {
setIsLoading(false);
}
};
const handleRequest = async (item: SearchResult) => {
const cost = item.mediaType === 'movie' ? costs.movie : costs.tv;
if (balance < cost) {
toast.error('Insufficient balance', {
description: `You need ${cost} $COOP to request this ${item.mediaType}.`,
});
return;
}
setIsRequesting(item.id);
try {
await overseerApi.request({
mediaType: item.mediaType,
mediaId: item.id,
title: item.title || item.name || 'Unknown',
});
toast.success('Request submitted!', {
description: `${item.title || item.name} has been added to the queue.`,
});
onRequested();
// Optionally close or clear results
} catch (error: any) {
toast.error(error.response?.data?.error || 'Failed to submit request');
} finally {
setIsRequesting(null);
}
};
const getStatusBadge = (status?: number) => {
switch (status) {
case 5: return <Badge className="bg-green-500">Available</Badge>;
case 4: return <Badge className="bg-blue-500">Partially Available</Badge>;
case 3: return <Badge className="bg-yellow-500">Processing</Badge>;
case 2: return <Badge className="bg-purple-500">Pending</Badge>;
default: return null;
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] h-[80vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="p-6 pb-0">
<DialogTitle>Request Content</DialogTitle>
<DialogDescription>
Search for movies or TV shows to add to the server.
<span className="block mt-1 font-semibold text-primary">
Costs: {costs.movie} $COOP (Movie) / {costs.tv} $COOP (TV)
</span>
</DialogDescription>
<div className="relative mt-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search for movies or shows..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-10 h-11"
autoFocus
/>
</div>
</DialogHeader>
<div className="flex-1 p-6 overflow-y-auto">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p>Searching Overseer...</p>
</div>
) : results.length > 0 ? (
<div className="space-y-4">
{results.map((item) => (
<div key={`${item.mediaType}-${item.id}`} className="flex gap-4 p-3 rounded-xl bg-muted/30 border border-transparent hover:border-primary/20 transition-colors group">
<div className="flex-none w-20 h-30 bg-muted rounded-md overflow-hidden relative">
{item.posterPath ? (
<img
src={`${OVERSEER_IMAGE_BASE}${item.posterPath}`}
alt={item.title || item.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
{item.mediaType === 'movie' ? <Film className="h-8 w-8 opacity-20" /> : <Tv className="h-8 w-8 opacity-20" />}
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col justify-between py-1">
<div>
<div className="flex items-start justify-between gap-2">
<h4 className="font-bold truncate group-hover:text-primary transition-colors">
{item.title || item.name}
</h4>
{getStatusBadge(item.mediaInfo?.status)}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground uppercase font-semibold">
{item.mediaType === 'movie' ? <Film className="h-3 w-3" /> : <Tv className="h-3 w-3" />}
{item.mediaType}
<span></span>
{item.releaseDate || item.firstAirDate || 'N/A'}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
{item.overview}
</p>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-xs font-bold text-primary">
{item.mediaType === 'movie' ? costs.movie : costs.tv} $COOP
</div>
{item.mediaInfo?.status && item.mediaInfo.status >= 4 ? (
<Button disabled size="sm" variant="ghost" className="h-8 px-3 text-green-500">
<CheckCircle2 className="mr-2 h-4 w-4" />
In Library
</Button>
) : (
<Button
size="sm"
onClick={() => handleRequest(item)}
disabled={isRequesting === item.id || balance < (item.mediaType === 'movie' ? costs.movie : costs.tv)}
className="h-8 px-4 rounded-full"
>
{isRequesting === item.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Request
</>
)}
</Button>
)}
</div>
</div>
</div>
))}
</div>
) : query.length > 2 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<AlertCircle className="h-12 w-12 opacity-20 mb-4" />
<p>No results found for "{query}"</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Search className="h-12 w-12 opacity-10 mb-4" />
<p>Type to search for movies and shows</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,92 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Wallet, Sparkles, ShieldCheck, Zap, ExternalLink } from 'lucide-react';
import { useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { useEffect } from 'react';
import { walletApi } from '@/lib/api';
import { toast } from 'sonner';
interface WelcomeOnboardingProps {
onStart: () => void;
onConnected: () => void;
}
export function WelcomeOnboarding({ onStart, onConnected }: WelcomeOnboardingProps) {
const { publicKey, connected } = useWallet();
useEffect(() => {
if (connected && publicKey) {
handleConnectWallet(publicKey.toString());
}
}, [connected, publicKey]);
const handleConnectWallet = async (address: string) => {
try {
await walletApi.connectWallet(address);
toast.success('Wallet connected to your account!');
onConnected();
} catch (error) {
toast.error('Failed to link wallet');
}
};
return (
<Card className="mb-8 overflow-hidden border-primary/20 bg-gradient-to-br from-primary/5 via-background to-background">
<CardContent className="p-0">
<div className="flex flex-col md:flex-row">
<div className="flex-1 p-8 space-y-6">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Welcome to the Ecosystem!</h2>
<p className="text-muted-foreground text-lg">
You're just one step away from earning rewards for your watch time.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="flex gap-3">
<div className="mt-1 bg-primary/10 p-2 rounded-lg h-fit">
<Zap className="h-4 w-4 text-primary" />
</div>
<div>
<h4 className="font-semibold">Automatic Rewards</h4>
<p className="text-sm text-muted-foreground">Credits are minted directly to your wallet while you watch.</p>
</div>
</div>
<div className="flex gap-3">
<div className="mt-1 bg-primary/10 p-2 rounded-lg h-fit">
<ShieldCheck className="h-4 w-4 text-primary" />
</div>
<div>
<h4 className="font-semibold">Secure & Private</h4>
<p className="text-sm text-muted-foreground">Your wallet is personal and secured on the Solana blockchain.</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-4 pt-2">
<Button size="lg" onClick={onStart} className="h-12 px-8 rounded-full shadow-lg shadow-primary/20">
<Wallet className="mr-2 h-5 w-5" />
Create Managed Wallet
</Button>
<div className="wallet-adapter-custom-wrapper">
<WalletMultiButton className="h-12 !rounded-full !bg-secondary !text-secondary-foreground hover:!bg-secondary/80" />
</div>
</div>
<p className="text-xs text-muted-foreground italic">
Choose "Create Managed Wallet" for an easy start, or "Select Wallet" to use your own (Phantom, Solflare, etc.)
</p>
</div>
<div className="hidden md:flex flex-none w-72 bg-primary/10 items-center justify-center border-l border-primary/10">
<Sparkles className="h-32 w-32 text-primary opacity-20 animate-pulse" />
</div>
</div>
</CardContent>
</Card>
);
}
+86 -33
View File
@@ -20,9 +20,16 @@ import {
} from 'lucide-react';
import { formatNumber, truncateAddress } from '@/lib/utils';
import { toast } from 'sonner';
import { useSocket } from '@/lib/socket';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { TransactionList } from './components/TransactionList';
import { WatchHistory } from './components/WatchHistory';
import { CreateWalletModal } from './components/CreateWalletModal';
import { WelcomeOnboarding } from './components/WelcomeOnboarding';
import { ActivityFeed } from './components/ActivityFeed';
import { SearchRequestModal } from './components/SearchRequestModal';
import { RequestHistory } from './components/RequestHistory';
import { Leaderboard } from './components/Leaderboard';
interface WalletData {
hasWallet: boolean;
@@ -39,7 +46,9 @@ export default function DashboardPage() {
const [wallet, setWallet] = useState<WalletData | null>(null);
const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 });
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const socket = useSocket();
useEffect(() => {
if (!isAuthenticated) {
@@ -50,6 +59,37 @@ export default function DashboardPage() {
loadData();
}, [isAuthenticated, router]);
useEffect(() => {
if (socket) {
socket.on('credits_earned', (data) => {
toast.success(`You earned ${data.amount} $COOP!`, {
description: `Watched: ${data.title}`,
});
loadData();
});
socket.on('bonus_received', (data) => {
toast.success(`Bonus Received: ${data.amount} $COOP!`, {
description: data.reason,
});
loadData();
});
socket.on('credits_spent', (data) => {
toast.info(`Requested: ${data.title}`, {
description: `Spent ${data.amount} $COOP`,
});
loadData();
});
return () => {
socket.off('credits_earned');
socket.off('bonus_received');
socket.off('credits_spent');
};
}
}, [socket]);
const loadData = async () => {
try {
const [walletRes, costsRes] = await Promise.all([
@@ -84,15 +124,25 @@ export default function DashboardPage() {
</Button>
)}
</div>
<Button variant="ghost" onClick={logout}>
Sign Out
</Button>
<div className="flex items-center gap-4">
<div className="wallet-adapter-custom-wrapper hidden md:block">
<WalletMultiButton className="!h-9 !px-4 !text-sm !rounded-md !bg-secondary !text-secondary-foreground hover:!bg-secondary/80" />
</div>
<Button variant="ghost" onClick={logout}>
Sign Out
</Button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
{/* Onboarding for new users */}
{!wallet?.hasWallet && !isLoading && (
<WelcomeOnboarding onStart={() => setIsCreateModalOpen(true)} onConnected={loadData} />
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Balance</CardTitle>
@@ -138,37 +188,21 @@ export default function DashboardPage() {
</CardContent>
</Card>
<Card>
<Card className="cursor-pointer hover:border-primary/50 transition-colors group" onClick={() => setIsSearchModalOpen(true)}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Request Cost</CardTitle>
<Film className="h-4 w-4 text-muted-foreground" />
<Film className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{requestCosts.movie} $COOP</div>
<p className="text-xs text-muted-foreground">
Per movie request
Click to request content
</p>
</CardContent>
</Card>
</div>
{/* Wallet Section */}
{!wallet?.hasWallet && (
<Card className="mb-8 border-dashed border-2">
<CardContent className="py-8 text-center">
<Wallet className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No Wallet Connected</h3>
<p className="text-muted-foreground mb-4">
Create a Solana wallet to start earning and spending $COOP tokens
</p>
<Button onClick={() => setIsCreateModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Wallet
</Button>
</CardContent>
</Card>
)}
{/* Wallet Section removed from here if redundant, or kept if we want it to show after creation */}
{wallet?.hasWallet && (
<Card className="mb-8">
<CardHeader>
@@ -206,20 +240,32 @@ export default function DashboardPage() {
)}
{/* Tabs */}
<Tabs defaultValue="transactions" className="space-y-4">
<TabsList>
<TabsTrigger value="transactions">Transactions</TabsTrigger>
<TabsTrigger value="history">Watch History</TabsTrigger>
</TabsList>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<Tabs defaultValue="transactions" className="space-y-4">
<TabsList>
<TabsTrigger value="transactions">Transactions</TabsTrigger>
<TabsTrigger value="history">Watch History</TabsTrigger>
<TabsTrigger value="requests">My Requests</TabsTrigger>
</TabsList>
<TabsContent value="transactions">
<TransactionList />
</TabsContent>
<TabsContent value="history">
<WatchHistory />
</TabsContent>
</Tabs>
<TabsContent value="history">
<WatchHistory />
</TabsContent>
<TabsContent value="requests">
<RequestHistory />
</TabsContent>
</Tabs>
</div>
<div className="lg:col-span-1 space-y-8">
<ActivityFeed />
<Leaderboard />
</div>
</div>
</main>
<CreateWalletModal
@@ -227,6 +273,13 @@ export default function DashboardPage() {
onClose={() => setIsCreateModalOpen(false)}
onCreated={loadData}
/>
<SearchRequestModal
open={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
onRequested={loadData}
costs={requestCosts}
balance={wallet?.balance || 0}
/>
</div>
);
}
+31 -5
View File
@@ -1,6 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@theme {
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
@layer base {
:root {
@@ -51,9 +76,10 @@
@layer base {
* {
@apply border-border;
border-color: hsl(var(--border));
}
body {
@apply bg-background text-foreground;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
}
+2 -2
View File
@@ -17,8 +17,8 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<html lang="en" className="dark">
<body className={`${inter.className} dark bg-slate-950 text-foreground`}>
<ProvidersWrapper>
{children}
<Toaster position="top-right" />
+22 -32
View File
@@ -1,9 +1,7 @@
'use client';
export const dynamic = 'force-dynamic';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { authApi } from '@/lib/api';
import { useStore } from '@/lib/store';
import { Button } from '@/components/ui/button';
@@ -13,17 +11,13 @@ import { toast } from 'sonner';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser, setToken, isAuthenticated } = useStore();
const { isAuthenticated } = useStore();
const [isLoading, setIsLoading] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
// Check for Plex OAuth callback
const code = searchParams.get('code');
if (code) {
handlePlexCallback(code);
}
}, [searchParams]);
setIsMounted(true);
}, []);
useEffect(() => {
if (isAuthenticated) {
@@ -31,26 +25,6 @@ export default function LoginPage() {
}
}, [isAuthenticated, router]);
const handlePlexCallback = async (code: string) => {
setIsLoading(true);
try {
const response = await authApi.plexCallback(code);
const { user, token } = response.data;
localStorage.setItem('token', token);
setUser(user);
setToken(token);
toast.success(`Welcome, ${user.plexUsername}!`);
router.push('/dashboard');
} catch (error) {
toast.error('Authentication failed');
console.error(error);
} finally {
setIsLoading(false);
}
};
const handlePlexLogin = async () => {
setIsLoading(true);
try {
@@ -63,8 +37,24 @@ export default function LoginPage() {
}
};
if (!isMounted) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Tv className="w-8 h-8 text-primary" />
</div>
<CardTitle className="text-2xl">CoopCredits</CardTitle>
<CardDescription>Loading...</CardDescription>
</CardHeader>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4">
<div className="min-h-screen flex items-center justify-center bg-slate-950 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
+180 -8
View File
@@ -1,14 +1,186 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tv, Wallet, Zap, Shield, ChevronRight, PlayCircle, Info } from 'lucide-react';
export default function HomePage() {
const router = useRouter();
export default function LandingPage() {
return (
<div className="min-h-screen bg-background flex flex-col selection:bg-primary selection:text-primary-foreground">
{/* Grid Pattern Overlay */}
<div className="fixed inset-0 z-0 opacity-[0.03] pointer-events-none"
style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '40px 40px' }} />
{/* Header */}
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-md">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-2 group cursor-pointer">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center transition-transform group-hover:rotate-12">
<Tv className="w-5 h-5 text-primary-foreground" />
</div>
<span className="font-bold text-xl tracking-tight">CoopCredits</span>
</div>
<nav className="hidden md:flex items-center gap-8">
<a href="#features" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Features</a>
<a href="#how-it-works" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">How it Works</a>
<Link href="/login">
<Button variant="outline" size="sm" className="rounded-full px-6">
Dashboard
</Button>
</Link>
</nav>
useEffect(() => {
router.push('/login');
}, [router]);
<Link href="/login" className="md:hidden">
<Button size="sm" className="rounded-full">Login</Button>
</Link>
</div>
</header>
return null;
<main className="flex-1 relative z-10">
{/* Hero Section */}
<section className="py-20 md:py-32 overflow-hidden">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto text-center space-y-8">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-bold uppercase tracking-widest border border-primary/20 animate-in fade-in slide-in-from-bottom-4 duration-500">
<Zap className="w-3 h-3" />
Live on Solana Devnet
</div>
<h1 className="text-5xl md:text-7xl font-extrabold tracking-tighter leading-[1.1] animate-in fade-in slide-in-from-bottom-8 duration-700 delay-100">
Turn your <span className="text-primary italic">Watch Time</span> <br />
into Digital Assets
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto animate-in fade-in slide-in-from-bottom-12 duration-1000 delay-200">
Earn $COOP tokens automatically while watching content on Plex.
Spend them to request new movies or TV shows on Overseer.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4 animate-in fade-in slide-in-from-bottom-16 duration-1000 delay-300">
<Link href="/login">
<Button size="lg" className="h-14 px-8 text-lg rounded-full shadow-lg shadow-primary/20 group">
Get Started Now
<ChevronRight className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
<a href="#how-it-works">
<Button variant="ghost" size="lg" className="h-14 px-8 text-lg rounded-full">
Learn More
</Button>
</a>
</div>
</div>
</div>
{/* Abstract background element */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[400px] bg-primary/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
</section>
{/* Features Section */}
<section id="features" className="py-24 border-y bg-muted/30">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
<div className="space-y-4 p-6 rounded-2xl bg-background border transition-all hover:shadow-xl hover:-translate-y-1">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-6">
<PlayCircle className="w-6 h-6 text-primary" />
</div>
<h3 className="text-xl font-bold">Watch to Earn</h3>
<p className="text-muted-foreground leading-relaxed">
Every minute you watch on Plex is tracked via Tautulli and converted into $COOP tokens. No extra steps required.
</p>
</div>
<div className="space-y-4 p-6 rounded-2xl bg-background border transition-all hover:shadow-xl hover:-translate-y-1">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-6">
<Shield className="w-6 h-6 text-primary" />
</div>
<h3 className="text-xl font-bold">Solana Powered</h3>
<p className="text-muted-foreground leading-relaxed">
Your rewards are minted on the Solana blockchain, ensuring full transparency, security, and low-latency transactions.
</p>
</div>
<div className="space-y-4 p-6 rounded-2xl bg-background border transition-all hover:shadow-xl hover:-translate-y-1">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-6">
<Zap className="w-6 h-6 text-primary" />
</div>
<h3 className="text-xl font-bold">Spend Credits</h3>
<p className="text-muted-foreground leading-relaxed">
Accumulate enough $COOP and use them to request new content on Overseer. You're the one in control of the library.
</p>
</div>
</div>
</div>
</section>
{/* How it Works Section */}
<section id="how-it-works" className="py-24">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto space-y-16">
<div className="text-center space-y-4">
<h2 className="text-3xl md:text-5xl font-bold tracking-tight">Simple. Transparent. Fun.</h2>
<p className="text-muted-foreground text-lg italic">"A new way to experience your media library."</p>
</div>
<div className="space-y-12">
<div className="flex gap-6 items-start">
<div className="flex-none w-10 h-10 rounded-full bg-primary flex items-center justify-center font-bold text-primary-foreground">1</div>
<div className="space-y-2 pt-1">
<h4 className="text-xl font-bold">Connect your Plex Account</h4>
<p className="text-muted-foreground">Sign in with Plex to link your account. We'll automatically generate a Solana wallet for you.</p>
</div>
</div>
<div className="flex gap-6 items-start">
<div className="flex-none w-10 h-10 rounded-full bg-primary flex items-center justify-center font-bold text-primary-foreground">2</div>
<div className="space-y-2 pt-1">
<h4 className="text-xl font-bold">Watch your Favorite Content</h4>
<p className="text-muted-foreground">Enjoy your movies and shows as usual. Tautulli reports your activity, and credits are minted to your wallet.</p>
</div>
</div>
<div className="flex gap-6 items-start">
<div className="flex-none w-10 h-10 rounded-full bg-primary flex items-center justify-center font-bold text-primary-foreground">3</div>
<div className="space-y-2 pt-1">
<h4 className="text-xl font-bold">Redeem and Repeat</h4>
<p className="text-muted-foreground">Use your $COOP to request new additions to the server via our Overseer integration.</p>
</div>
</div>
</div>
<div className="bg-card border p-8 rounded-3xl flex flex-col md:flex-row items-center gap-8 shadow-2xl shadow-primary/5">
<div className="flex-1 space-y-4 text-center md:text-left">
<h3 className="text-2xl font-bold">Ready to join the ecosystem?</h3>
<p className="text-muted-foreground">Join hundreds of other users earning rewards for their watch time.</p>
</div>
<Link href="/login">
<Button size="lg" className="rounded-full px-8 h-12">Start Now</Button>
</Link>
</div>
</div>
</div>
</section>
</main>
{/* Footer */}
<footer className="border-t py-12 bg-muted/20">
<div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
<div className="flex items-center gap-2 grayscale opacity-50">
<Tv className="w-5 h-5" />
<span className="font-bold">CoopCredits</span>
</div>
<div className="text-sm text-muted-foreground text-center md:text-right">
<p>© {new Date().getFullYear()} Hoboken Chicken. All rights reserved.</p>
<p className="mt-1 flex items-center justify-center md:justify-end gap-1">
Built with <Zap className="w-3 h-3 text-yellow-500 fill-yellow-500" /> on Solana
</p>
</div>
</div>
</div>
</footer>
</div>
);
}
+14 -8
View File
@@ -1,13 +1,19 @@
'use client';
import dynamic from 'next/dynamic';
import { ReactNode } from 'react';
const DynamicProviders = dynamic(
() => import('./providers').then((mod) => ({ default: mod.Providers })),
{ ssr: false }
);
import { ReactNode, useEffect, useState } from 'react';
import { Providers } from './providers';
export function ProvidersWrapper({ children }: { children: ReactNode }) {
return <DynamicProviders>{children}</DynamicProviders>;
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Prevent hydration issues by not rendering Providers until mounted
if (!mounted) {
return <>{children}</>;
}
return <Providers>{children}</Providers>;
}
+23 -2
View File
@@ -1,14 +1,29 @@
'use client';
import { ReactNode, useEffect, useState } from 'react';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { ThemeProvider } from 'next-themes';
import { useStore } from '@/lib/store';
import { authApi } from '@/lib/api';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter, SolflareWalletAdapter } from '@solana/wallet-adapter-wallets';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl } from '@solana/web3.js';
export function Providers({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
const { setUser, setToken } = useStore();
const network = WalletAdapterNetwork.Devnet;
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
const wallets = useMemo(
() => [
new PhantomWalletAdapter(),
new SolflareWalletAdapter(),
],
[]
);
useEffect(() => {
setMounted(true);
@@ -33,7 +48,13 @@ export function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{children}
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{children}
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
</ThemeProvider>
);
}
+2
View File
@@ -47,6 +47,7 @@ export const userApi = {
api.get(`/users/me/watch-history?page=${page}&limit=${limit}`),
getRequests: (page = 1, limit = 20, status?: string) =>
api.get(`/users/me/requests?page=${page}&limit=${limit}${status ? `&status=${status}` : ''}`),
getLeaderboard: () => api.get('/users/leaderboard'),
};
// Wallet API
@@ -62,6 +63,7 @@ export const transactionApi = {
getTransactions: (page = 1, limit = 20, type?: string) =>
api.get(`/transactions?page=${page}&limit=${limit}${type ? `&type=${type}` : ''}`),
getStats: () => api.get('/transactions/stats'),
getRecentActivity: () => api.get('/transactions/recent'),
};
// Admin API
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { useStore } from './store';
const SOCKET_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export const useSocket = () => {
const { token, isAuthenticated } = useStore();
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
if (isAuthenticated && token) {
// Connect to socket
socketRef.current = io(SOCKET_URL, {
auth: { token },
transports: ['websocket'],
});
socketRef.current.on('connect', () => {
console.log('Connected to socket');
socketRef.current?.emit('subscribe_transactions');
});
socketRef.current.on('connect_error', (error) => {
console.error('Socket connection error:', error);
});
return () => {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
};
}
}, [isAuthenticated, token]);
return socketRef.current;
};
-57
View File
@@ -1,57 +0,0 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;
+19 -5
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
File diff suppressed because one or more lines are too long