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
+21
View File
@@ -0,0 +1,21 @@
# Frontend Todo List
## Phase 1: Accessibility & Onboarding (Priority: 1)
- [x] **Landing Page**: Marketing page at `/` with "How it Works".
- [x] **Onboarding Flow**: Better guided steps for new users to set up their wallet.
- [x] **Mobile Responsiveness**: Audit and fix layout shifts on small screens.
## Phase 2: Engagement & Real-time (Priority: 2)
- [x] **Socket.io Integration**: Connect to backend for live watch events.
- [x] **Live Reward Toasts**: Show "You earned 10 $COOP" alerts in real-time.
- [x] **Activity Feed**: A small sidebar or section for "Global Recent Earners".
## Phase 3: Core Spend Loop (Priority: 3)
- [x] **Overseer Request UI**: Create `SearchRequestModal` component.
- [x] **Request History**: Show status of Overseer requests (Pending/Processing/Available).
- [x] **Balance Refresh**: Ensure balance updates immediately after a request.
## Phase 4: Advanced Features (Priority: 4)
- [x] **Browser Wallets**: Connect Phantom/Solflare/Backpack.
- [ ] **Referral System**: Earn bonus $COOP for inviting other Plex users. (Requires DB Migration)
- [x] **Leaderboard**: Weekly/Monthly top watchers.
@@ -0,0 +1,172 @@
-- CreateEnum
CREATE TYPE "TransactionType" AS ENUM ('EARN', 'SPEND', 'BONUS', 'ADJUSTMENT');
-- CreateEnum
CREATE TYPE "RequestStatus" AS ENUM ('PENDING', 'APPROVED', 'DECLINED', 'COMPLETED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"plex_id" TEXT NOT NULL,
"plex_username" TEXT NOT NULL,
"email" TEXT,
"is_admin" BOOLEAN NOT NULL DEFAULT false,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"wallet_address" TEXT,
"encrypted_private_key" TEXT,
"total_earned" INTEGER NOT NULL DEFAULT 0,
"total_spent" INTEGER NOT NULL DEFAULT 0,
"watch_time_minutes" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "transactions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"amount" INTEGER NOT NULL,
"watch_event_id" TEXT,
"request_id" TEXT,
"solana_signature" TEXT,
"description" TEXT,
"content_title" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "watch_events" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"session_id" TEXT NOT NULL,
"rating_key" TEXT NOT NULL,
"content_type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"grandparent_title" TEXT,
"duration" INTEGER NOT NULL,
"percent_complete" INTEGER NOT NULL,
"credits_earned" INTEGER NOT NULL,
"is_processed" BOOLEAN NOT NULL DEFAULT false,
"watched_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "watch_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "content_requests" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"overseer_request_id" INTEGER NOT NULL,
"media_type" TEXT NOT NULL,
"tmdb_id" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"credits_cost" INTEGER NOT NULL,
"status" "RequestStatus" NOT NULL DEFAULT 'PENDING',
"requested_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "content_requests_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "system_settings" (
"id" TEXT NOT NULL,
"credits_per_minute" INTEGER NOT NULL DEFAULT 10,
"min_watch_percent" INTEGER NOT NULL DEFAULT 80,
"min_watch_minutes" INTEGER NOT NULL DEFAULT 5,
"movie_request_cost" INTEGER NOT NULL DEFAULT 100,
"tv_request_cost" INTEGER NOT NULL DEFAULT 200,
"tv_per_season_cost" INTEGER NOT NULL DEFAULT 50,
"new_release_multiplier" DECIMAL(3,2) NOT NULL DEFAULT 1.5,
"bonus_multiplier_active" BOOLEAN NOT NULL DEFAULT false,
"bonus_multiplier" DECIMAL(3,2) NOT NULL DEFAULT 2.0,
"minting_paused" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" TEXT,
CONSTRAINT "system_settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_plex_id_key" ON "users"("plex_id");
-- CreateIndex
CREATE UNIQUE INDEX "users_wallet_address_key" ON "users"("wallet_address");
-- CreateIndex
CREATE INDEX "users_plex_id_idx" ON "users"("plex_id");
-- CreateIndex
CREATE INDEX "users_wallet_address_idx" ON "users"("wallet_address");
-- CreateIndex
CREATE UNIQUE INDEX "transactions_request_id_key" ON "transactions"("request_id");
-- CreateIndex
CREATE INDEX "transactions_user_id_idx" ON "transactions"("user_id");
-- CreateIndex
CREATE INDEX "transactions_type_idx" ON "transactions"("type");
-- CreateIndex
CREATE INDEX "transactions_created_at_idx" ON "transactions"("created_at");
-- CreateIndex
CREATE INDEX "watch_events_user_id_idx" ON "watch_events"("user_id");
-- CreateIndex
CREATE INDEX "watch_events_watched_at_idx" ON "watch_events"("watched_at");
-- CreateIndex
CREATE UNIQUE INDEX "watch_events_session_id_key" ON "watch_events"("session_id");
-- CreateIndex
CREATE INDEX "content_requests_user_id_idx" ON "content_requests"("user_id");
-- CreateIndex
CREATE INDEX "content_requests_status_idx" ON "content_requests"("status");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
-- CreateIndex
CREATE INDEX "sessions_token_idx" ON "sessions"("token");
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_watch_event_id_fkey" FOREIGN KEY ("watch_event_id") REFERENCES "watch_events"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_request_id_fkey" FOREIGN KEY ("request_id") REFERENCES "content_requests"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "watch_events" ADD CONSTRAINT "watch_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "content_requests" ADD CONSTRAINT "content_requests_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+1 -1
View File
@@ -1,6 +1,6 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["linux-musl-openssl-3.0.x"]
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "debian-openssl-3.0.x"]
}
datasource db {
+8
View File
@@ -3,6 +3,7 @@ import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/
import { prisma } from '../utils/prisma';
import { asyncHandler } from '../middleware/errorHandler';
import { mintTokens } from '../services/solana';
import { io } from '../index';
const router = Router();
@@ -227,6 +228,13 @@ router.post('/users/:id/bonus',
}
});
// Emit real-time update via WebSocket
io.to(`user:${user.id}`).emit('bonus_received', {
amount,
reason: reason || 'Admin Bonus',
transaction
});
res.json({
success: true,
amount,
+16 -33
View File
@@ -165,45 +165,28 @@ router.get('/admin/all',
})
);
// Admin: Get system-wide stats
// Get system-wide stats
router.get('/admin/stats',
// ... (keeping existing code)
);
// Get recent global activity
router.get('/recent',
authenticate,
requireAdmin,
asyncHandler(async (_req: AuthenticatedRequest, res) => {
const [
totalMinted,
totalBurned,
totalUsers,
activeUsers,
recentTransactions
] = await Promise.all([
prisma.transaction.aggregate({
where: { type: 'EARN' },
_sum: { amount: true }
}),
prisma.transaction.aggregate({
where: { type: 'SPEND' },
_sum: { amount: true }
}),
prisma.user.count(),
prisma.user.count({ where: { walletAddress: { not: null } } }),
prisma.transaction.count({
where: {
createdAt: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000)
const transactions = await prisma.transaction.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
include: {
user: {
select: {
plexUsername: true
}
}
})
]);
res.json({
totalMinted: totalMinted._sum.amount || 0,
totalBurned: totalBurned._sum.amount || 0,
netSupply: (totalMinted._sum.amount || 0) - (totalBurned._sum.amount || 0),
totalUsers,
activeUsers,
recentTransactions
}
});
res.json({ transactions });
})
);
+30
View File
@@ -132,4 +132,34 @@ router.get('/me/requests',
})
);
// Get leaderboard
router.get('/leaderboard',
authenticate,
asyncHandler(async (_req: AuthenticatedRequest, res) => {
const topEarners = await prisma.user.findMany({
orderBy: { totalEarned: 'desc' },
take: 10,
select: {
id: true,
plexUsername: true,
totalEarned: true,
watchTimeMinutes: true
}
});
const topWatchers = await prisma.user.findMany({
orderBy: { watchTimeMinutes: 'desc' },
take: 10,
select: {
id: true,
plexUsername: true,
totalEarned: true,
watchTimeMinutes: true
}
});
res.json({ topEarners, topWatchers });
})
);
export { router as userRouter };
+1
View File
@@ -54,6 +54,7 @@ services:
- SOLANA_MINT_AUTHORITY_KEYPAIR=${SOLANA_MINT_AUTHORITY_KEYPAIR}
- PLEX_CLIENT_ID=${PLEX_CLIENT_ID}
- PLEX_CLIENT_SECRET=${PLEX_CLIENT_SECRET}
- PLEX_REDIRECT_URI=${PLEX_REDIRECT_URI}
- TAUTULLI_URL=${TAUTULLI_URL}
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
- OVERSEER_URL=${OVERSEER_URL}
+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
+764
View File
@@ -0,0 +1,764 @@
#!/bin/sh
set -e
# Docker Engine for Linux installation script.
#
# This script is intended as a convenient way to configure docker's package
# repositories and to install Docker Engine, This script is not recommended
# for production environments. Before running this script, make yourself familiar
# with potential risks and limitations, and refer to the installation manual
# at https://docs.docker.com/engine/install/ for alternative installation methods.
#
# The script:
#
# - Requires `root` or `sudo` privileges to run.
# - Attempts to detect your Linux distribution and version and configure your
# package management system for you.
# - Doesn't allow you to customize most installation parameters.
# - Installs dependencies and recommendations without asking for confirmation.
# - Installs the latest stable release (by default) of Docker CLI, Docker Engine,
# Docker Buildx, Docker Compose, containerd, and runc. When using this script
# to provision a machine, this may result in unexpected major version upgrades
# of these packages. Always test upgrades in a test environment before
# deploying to your production systems.
# - Isn't designed to upgrade an existing Docker installation. When using the
# script to update an existing installation, dependencies may not be updated
# to the expected version, resulting in outdated versions.
#
# Source code is available at https://github.com/docker/docker-install/
#
# Usage
# ==============================================================================
#
# To install the latest stable versions of Docker CLI, Docker Engine, and their
# dependencies:
#
# 1. download the script
#
# $ curl -fsSL https://get.docker.com -o install-docker.sh
#
# 2. verify the script's content
#
# $ cat install-docker.sh
#
# 3. run the script with --dry-run to verify the steps it executes
#
# $ sh install-docker.sh --dry-run
#
# 4. run the script either as root, or using sudo to perform the installation.
#
# $ sudo sh install-docker.sh
#
# Command-line options
# ==============================================================================
#
# --version <VERSION>
# Use the --version option to install a specific version, for example:
#
# $ sudo sh install-docker.sh --version 23.0
#
# --channel <stable|test>
#
# Use the --channel option to install from an alternative installation channel.
# The following example installs the latest versions from the "test" channel,
# which includes pre-releases (alpha, beta, rc):
#
# $ sudo sh install-docker.sh --channel test
#
# Alternatively, use the script at https://test.docker.com, which uses the test
# channel as default.
#
# --mirror <Aliyun|AzureChinaCloud>
#
# Use the --mirror option to install from a mirror supported by this script.
# Available mirrors are "Aliyun" (https://mirrors.aliyun.com/docker-ce), and
# "AzureChinaCloud" (https://mirror.azure.cn/docker-ce), for example:
#
# $ sudo sh install-docker.sh --mirror AzureChinaCloud
#
# --setup-repo
#
# Use the --setup-repo option to configure Docker's package repositories without
# installing Docker packages. This is useful when you want to add the repository
# but install packages separately:
#
# $ sudo sh install-docker.sh --setup-repo
#
# Automatic Service Start
#
# By default, this script automatically starts the Docker daemon and enables the docker
# service after installation if systemd is used as init.
#
# If you prefer to start the service manually, use the --no-autostart option:
#
# $ sudo sh install-docker.sh --no-autostart
#
# Note: Starting the service requires appropriate privileges to manage system services.
#
# ==============================================================================
# Git commit from https://github.com/docker/docker-install when
# the script was uploaded (Should only be modified by upload job):
SCRIPT_COMMIT_SHA="8fb5881103ac6f2fb404605d6d5b1f84244f3896"
# strip "v" prefix if present
VERSION="${VERSION#v}"
# The channel to install from:
# * stable
# * test
DEFAULT_CHANNEL_VALUE="stable"
if [ -z "$CHANNEL" ]; then
CHANNEL=$DEFAULT_CHANNEL_VALUE
fi
DEFAULT_DOWNLOAD_URL="https://download.docker.com"
if [ -z "$DOWNLOAD_URL" ]; then
DOWNLOAD_URL=$DEFAULT_DOWNLOAD_URL
fi
DEFAULT_REPO_FILE="docker-ce.repo"
if [ -z "$REPO_FILE" ]; then
REPO_FILE="$DEFAULT_REPO_FILE"
# Automatically default to a staging repo fora
# a staging download url (download-stage.docker.com)
case "$DOWNLOAD_URL" in
*-stage*) REPO_FILE="docker-ce-staging.repo";;
esac
fi
mirror=''
DRY_RUN=${DRY_RUN:-}
REPO_ONLY=${REPO_ONLY:-0}
NO_AUTOSTART=${NO_AUTOSTART:-0}
while [ $# -gt 0 ]; do
case "$1" in
--channel)
CHANNEL="$2"
shift
;;
--dry-run)
DRY_RUN=1
;;
--mirror)
mirror="$2"
shift
;;
--version)
VERSION="${2#v}"
shift
;;
--setup-repo)
REPO_ONLY=1
shift
;;
--no-autostart)
NO_AUTOSTART=1
;;
--*)
echo "Illegal option $1"
;;
esac
shift $(( $# > 0 ? 1 : 0 ))
done
case "$mirror" in
Aliyun)
DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce"
;;
AzureChinaCloud)
DOWNLOAD_URL="https://mirror.azure.cn/docker-ce"
;;
"")
;;
*)
>&2 echo "unknown mirror '$mirror': use either 'Aliyun', or 'AzureChinaCloud'."
exit 1
;;
esac
case "$CHANNEL" in
stable|test)
;;
*)
>&2 echo "unknown CHANNEL '$CHANNEL': use either stable or test."
exit 1
;;
esac
command_exists() {
command -v "$@" > /dev/null 2>&1
}
# version_gte checks if the version specified in $VERSION is at least the given
# SemVer (Maj.Minor[.Patch]), or CalVer (YY.MM) version.It returns 0 (success)
# if $VERSION is either unset (=latest) or newer or equal than the specified
# version, or returns 1 (fail) otherwise.
#
# examples:
#
# VERSION=23.0
# version_gte 23.0 // 0 (success)
# version_gte 20.10 // 0 (success)
# version_gte 19.03 // 0 (success)
# version_gte 26.1 // 1 (fail)
version_gte() {
if [ -z "$VERSION" ]; then
return 0
fi
version_compare "$VERSION" "$1"
}
# version_compare compares two version strings (either SemVer (Major.Minor.Path),
# or CalVer (YY.MM) version strings. It returns 0 (success) if version A is newer
# or equal than version B, or 1 (fail) otherwise. Patch releases and pre-release
# (-alpha/-beta) are not taken into account
#
# examples:
#
# version_compare 23.0.0 20.10 // 0 (success)
# version_compare 23.0 20.10 // 0 (success)
# version_compare 20.10 19.03 // 0 (success)
# version_compare 20.10 20.10 // 0 (success)
# version_compare 19.03 20.10 // 1 (fail)
version_compare() (
set +x
yy_a="$(echo "$1" | cut -d'.' -f1)"
yy_b="$(echo "$2" | cut -d'.' -f1)"
if [ "$yy_a" -lt "$yy_b" ]; then
return 1
fi
if [ "$yy_a" -gt "$yy_b" ]; then
return 0
fi
mm_a="$(echo "$1" | cut -d'.' -f2)"
mm_b="$(echo "$2" | cut -d'.' -f2)"
# trim leading zeros to accommodate CalVer
mm_a="${mm_a#0}"
mm_b="${mm_b#0}"
if [ "${mm_a:-0}" -lt "${mm_b:-0}" ]; then
return 1
fi
return 0
)
is_dry_run() {
if [ -z "$DRY_RUN" ]; then
return 1
else
return 0
fi
}
is_wsl() {
case "$(uname -r)" in
*microsoft* ) true ;; # WSL 2
*Microsoft* ) true ;; # WSL 1
* ) false;;
esac
}
is_darwin() {
case "$(uname -s)" in
*darwin* ) true ;;
*Darwin* ) true ;;
* ) false;;
esac
}
deprecation_notice() {
distro=$1
distro_version=$2
echo
printf "\033[91;1mDEPRECATION WARNING\033[0m\n"
printf " This Linux distribution (\033[1m%s %s\033[0m) reached end-of-life and is no longer supported by this script.\n" "$distro" "$distro_version"
echo " No updates or security fixes will be released for this distribution, and users are recommended"
echo " to upgrade to a currently maintained version of $distro."
echo
printf "Press \033[1mCtrl+C\033[0m now to abort this script, or wait for the installation to continue."
echo
sleep 10
}
get_distribution() {
lsb_dist=""
# Every system that we officially support has /etc/os-release
if [ -r /etc/os-release ]; then
lsb_dist="$(. /etc/os-release && echo "$ID")"
fi
# Returning an empty string here should be alright since the
# case statements don't act unless you provide an actual value
echo "$lsb_dist"
}
start_docker_daemon() {
# Use systemctl if available (for systemd-based systems)
if command_exists systemctl; then
is_dry_run || >&2 echo "Using systemd to manage Docker service"
if (
is_dry_run || set -x
$sh_c systemctl enable --now docker.service 2>/dev/null
); then
is_dry_run || echo "INFO: Docker daemon enabled and started" >&2
else
is_dry_run || echo "WARNING: unable to enable the docker service" >&2
fi
else
# No service management available (container environment)
if ! is_dry_run; then
>&2 echo "Note: Running in a container environment without service management"
>&2 echo "Docker daemon cannot be started automatically in this environment"
>&2 echo "The Docker packages have been installed successfully"
fi
fi
>&2 echo
}
echo_docker_as_nonroot() {
if is_dry_run; then
return
fi
if command_exists docker && [ -e /var/run/docker.sock ]; then
(
set -x
$sh_c 'docker version'
) || true
fi
# intentionally mixed spaces and tabs here -- tabs are stripped by "<<-EOF", spaces are kept in the output
echo
echo "================================================================================"
echo
if version_gte "20.10"; then
echo "To run Docker as a non-privileged user, consider setting up the"
echo "Docker daemon in rootless mode for your user:"
echo
echo " dockerd-rootless-setuptool.sh install"
echo
echo "Visit https://docs.docker.com/go/rootless/ to learn about rootless mode."
echo
fi
echo
echo "To run the Docker daemon as a fully privileged service, but granting non-root"
echo "users access, refer to https://docs.docker.com/go/daemon-access/"
echo
echo "WARNING: Access to the remote API on a privileged Docker daemon is equivalent"
echo " to root access on the host. Refer to the 'Docker daemon attack surface'"
echo " documentation for details: https://docs.docker.com/go/attack-surface/"
echo
echo "================================================================================"
echo
}
# Check if this is a forked Linux distro
check_forked() {
# Check for lsb_release command existence, it usually exists in forked distros
if command_exists lsb_release; then
# Check if the `-u` option is supported
set +e
lsb_release -a -u > /dev/null 2>&1
lsb_release_exit_code=$?
set -e
# Check if the command has exited successfully, it means we're in a forked distro
if [ "$lsb_release_exit_code" = "0" ]; then
# Print info about current distro
cat <<-EOF
You're using '$lsb_dist' version '$dist_version'.
EOF
# Get the upstream release info
lsb_dist=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'id' | cut -d ':' -f 2 | tr -d '[:space:]')
dist_version=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'codename' | cut -d ':' -f 2 | tr -d '[:space:]')
# Print info about upstream distro
cat <<-EOF
Upstream release is '$lsb_dist' version '$dist_version'.
EOF
else
if [ -r /etc/debian_version ] && [ "$lsb_dist" != "ubuntu" ] && [ "$lsb_dist" != "raspbian" ]; then
if [ "$lsb_dist" = "osmc" ]; then
# OSMC runs Raspbian
lsb_dist=raspbian
else
# We're Debian and don't even know it!
lsb_dist=debian
fi
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
case "$dist_version" in
13|14|forky)
dist_version="trixie"
;;
12)
dist_version="bookworm"
;;
11)
dist_version="bullseye"
;;
10)
dist_version="buster"
;;
9)
dist_version="stretch"
;;
8)
dist_version="jessie"
;;
esac
fi
fi
fi
}
do_install() {
echo "# Executing docker install script, commit: $SCRIPT_COMMIT_SHA"
if [ "$REPO_ONLY" != "1" ] && command_exists docker; then
cat >&2 <<-'EOF'
Warning: the "docker" command appears to already exist on this system.
If you already have Docker installed, this script can cause trouble, which is
why we're displaying this warning and provide the opportunity to cancel the
installation.
If you installed the current Docker package using this script and are using it
again to update Docker, you can ignore this message, but be aware that the
script resets any custom changes in the deb and rpm repo configuration
files to match the parameters passed to the script.
You may press Ctrl+C now to abort this script.
EOF
( set -x; sleep 20 )
fi
user="$(id -un 2>/dev/null || true)"
sh_c='sh -c'
if [ "$user" != 'root' ]; then
if command_exists sudo; then
sh_c='sudo -E sh -c'
elif command_exists su; then
sh_c='su -c'
else
cat >&2 <<-'EOF'
Error: this installer needs the ability to run commands as root.
We are unable to find either "sudo" or "su" available to make this happen.
EOF
exit 1
fi
fi
if is_dry_run; then
sh_c="echo"
fi
# perform some very rudimentary platform detection
lsb_dist=$( get_distribution )
lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')"
if is_wsl; then
echo
echo "WSL DETECTED: We recommend using Docker Desktop for Windows."
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop/"
echo
cat >&2 <<-'EOF'
You may press Ctrl+C now to abort this script.
EOF
( set -x; sleep 20 )
fi
case "$lsb_dist" in
ubuntu)
if command_exists lsb_release; then
dist_version="$(lsb_release --codename | cut -f2)"
fi
if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then
dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")"
fi
;;
debian|raspbian)
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
case "$dist_version" in
13)
dist_version="trixie"
;;
12)
dist_version="bookworm"
;;
11)
dist_version="bullseye"
;;
10)
dist_version="buster"
;;
9)
dist_version="stretch"
;;
8)
dist_version="jessie"
;;
esac
;;
centos|rhel|rocky)
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
fi
;;
*)
if command_exists lsb_release; then
dist_version="$(lsb_release --release | cut -f2)"
fi
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
fi
;;
esac
# Check if this is a forked Linux distro
check_forked
# Print deprecation warnings for distro versions that recently reached EOL,
# but may still be commonly used (especially LTS versions).
case "$lsb_dist.$dist_version" in
centos.8|centos.7|rhel.7)
deprecation_notice "$lsb_dist" "$dist_version"
;;
debian.buster|debian.stretch|debian.jessie)
deprecation_notice "$lsb_dist" "$dist_version"
;;
raspbian.buster|raspbian.stretch|raspbian.jessie)
deprecation_notice "$lsb_dist" "$dist_version"
;;
ubuntu.focal|ubuntu.bionic|ubuntu.xenial|ubuntu.trusty)
deprecation_notice "$lsb_dist" "$dist_version"
;;
ubuntu.oracular|ubuntu.mantic|ubuntu.lunar|ubuntu.kinetic|ubuntu.impish|ubuntu.hirsute|ubuntu.groovy|ubuntu.eoan|ubuntu.disco|ubuntu.cosmic)
deprecation_notice "$lsb_dist" "$dist_version"
;;
fedora.*)
if [ "$dist_version" -lt 41 ]; then
deprecation_notice "$lsb_dist" "$dist_version"
fi
;;
esac
# Run setup for each distro accordingly
case "$lsb_dist" in
ubuntu|debian|raspbian)
pre_reqs="ca-certificates curl"
apt_repo="deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] $DOWNLOAD_URL/linux/$lsb_dist $dist_version $CHANNEL"
(
if ! is_dry_run; then
set -x
fi
$sh_c 'apt-get -qq update >/dev/null'
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pre_reqs >/dev/null"
$sh_c 'install -m 0755 -d /etc/apt/keyrings'
$sh_c "curl -fsSL \"$DOWNLOAD_URL/linux/$lsb_dist/gpg\" -o /etc/apt/keyrings/docker.asc"
$sh_c "chmod a+r /etc/apt/keyrings/docker.asc"
$sh_c "echo \"$apt_repo\" > /etc/apt/sources.list.d/docker.list"
$sh_c 'apt-get -qq update >/dev/null'
)
if [ "$REPO_ONLY" = "1" ]; then
exit 0
fi
pkg_version=""
if [ -n "$VERSION" ]; then
if is_dry_run; then
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
else
# Will work for incomplete versions IE (17.12), but may not actually grab the "latest" if in the test channel
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/~ce~.*/g' | sed 's/-/.*/g')"
search_command="apt-cache madison docker-ce | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
pkg_version="$($sh_c "$search_command")"
echo "INFO: Searching repository for VERSION '$VERSION'"
echo "INFO: $search_command"
if [ -z "$pkg_version" ]; then
echo
echo "ERROR: '$VERSION' not found amongst apt-cache madison results"
echo
exit 1
fi
if version_gte "18.09"; then
search_command="apt-cache madison docker-ce-cli | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
echo "INFO: $search_command"
cli_pkg_version="=$($sh_c "$search_command")"
fi
pkg_version="=$pkg_version"
fi
fi
(
pkgs="docker-ce${pkg_version%=}"
if version_gte "18.09"; then
# older versions didn't ship the cli and containerd as separate packages
pkgs="$pkgs docker-ce-cli${cli_pkg_version%=} containerd.io"
fi
if version_gte "20.10"; then
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
fi
if version_gte "23.0"; then
pkgs="$pkgs docker-buildx-plugin"
fi
if version_gte "28.2"; then
pkgs="$pkgs docker-model-plugin"
fi
if ! is_dry_run; then
set -x
fi
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pkgs >/dev/null"
)
if [ "$NO_AUTOSTART" != "1" ]; then
start_docker_daemon
fi
echo_docker_as_nonroot
exit 0
;;
centos|fedora|rhel|rocky)
if [ "$(uname -m)" = "s390x" ]; then
echo "Effective v27.5, please consult RHEL distro statement for s390x support."
exit 1
fi
repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE"
(
if ! is_dry_run; then
set -x
fi
if command_exists dnf5; then
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
$sh_c "dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile='$repo_file_url'"
if [ "$CHANNEL" != "stable" ]; then
$sh_c "dnf5 config-manager setopt \"docker-ce-*.enabled=0\""
$sh_c "dnf5 config-manager setopt \"docker-ce-$CHANNEL.enabled=1\""
fi
$sh_c "dnf makecache"
elif command_exists dnf; then
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
$sh_c "dnf config-manager --add-repo $repo_file_url"
if [ "$CHANNEL" != "stable" ]; then
$sh_c "dnf config-manager --set-disabled \"docker-ce-*\""
$sh_c "dnf config-manager --set-enabled \"docker-ce-$CHANNEL\""
fi
$sh_c "dnf makecache"
else
$sh_c "yum -y -q install yum-utils"
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
$sh_c "yum-config-manager --add-repo $repo_file_url"
if [ "$CHANNEL" != "stable" ]; then
$sh_c "yum-config-manager --disable \"docker-ce-*\""
$sh_c "yum-config-manager --enable \"docker-ce-$CHANNEL\""
fi
$sh_c "yum makecache"
fi
)
if [ "$REPO_ONLY" = "1" ]; then
exit 0
fi
pkg_version=""
if command_exists dnf; then
pkg_manager="dnf"
pkg_manager_flags="-y -q --best"
else
pkg_manager="yum"
pkg_manager_flags="-y -q"
fi
if [ -n "$VERSION" ]; then
if is_dry_run; then
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
else
if [ "$lsb_dist" = "fedora" ]; then
pkg_suffix="fc$dist_version"
else
pkg_suffix="el"
fi
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g').*$pkg_suffix"
search_command="$pkg_manager list --showduplicates docker-ce | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
pkg_version="$($sh_c "$search_command")"
echo "INFO: Searching repository for VERSION '$VERSION'"
echo "INFO: $search_command"
if [ -z "$pkg_version" ]; then
echo
echo "ERROR: '$VERSION' not found amongst $pkg_manager list results"
echo
exit 1
fi
if version_gte "18.09"; then
# older versions don't support a cli package
search_command="$pkg_manager list --showduplicates docker-ce-cli | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
cli_pkg_version="$($sh_c "$search_command" | cut -d':' -f 2)"
fi
# Cut out the epoch and prefix with a '-'
pkg_version="-$(echo "$pkg_version" | cut -d':' -f 2)"
fi
fi
(
pkgs="docker-ce$pkg_version"
if version_gte "18.09"; then
# older versions didn't ship the cli and containerd as separate packages
if [ -n "$cli_pkg_version" ]; then
pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io"
else
pkgs="$pkgs docker-ce-cli containerd.io"
fi
fi
if version_gte "20.10"; then
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
fi
if version_gte "23.0"; then
pkgs="$pkgs docker-buildx-plugin docker-model-plugin"
fi
if ! is_dry_run; then
set -x
fi
$sh_c "$pkg_manager $pkg_manager_flags install $pkgs"
)
if [ "$NO_AUTOSTART" != "1" ]; then
start_docker_daemon
fi
echo_docker_as_nonroot
exit 0
;;
sles)
echo "Effective v27.5, please consult SLES distro statement for s390x support."
exit 1
;;
*)
if [ -z "$lsb_dist" ]; then
if is_darwin; then
echo
echo "ERROR: Unsupported operating system 'macOS'"
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop"
echo
exit 1
fi
fi
echo
echo "ERROR: Unsupported distribution '$lsb_dist'"
echo
exit 1
;;
esac
exit 1
}
# wrapped up in a function so that we have some protection against only getting
# half the file during "curl | sh"
do_install
+662 -3969
View File
File diff suppressed because it is too large Load Diff