chore: final solana purge

This commit is contained in:
2026-04-22 13:37:22 -04:00
parent 4bfcbb6b7e
commit f944b1f9b4
15 changed files with 374 additions and 1938 deletions
+7 -30
View File
@@ -1,12 +1,12 @@
import { Router } from "express";
import { io } from "../index";
import {
type AuthenticatedRequest,
authenticate,
AuthenticatedRequest,
requireAdmin,
} from "../middleware/auth";
import { asyncHandler } from "../middleware/errorHandler";
import { prisma } from "../utils/prisma";
import { asyncHandler } from "../middleware/errorHandler";
import { io } from "../index";
const router = Router();
@@ -20,7 +20,6 @@ router.get(
res.json(settings);
}),
);
router.put(
"/settings",
authenticate,
@@ -44,7 +43,6 @@ router.put(
res.json(settings);
}),
);
router.post(
"/pause",
authenticate,
@@ -54,10 +52,9 @@ router.post(
where: { id: "default" },
data: { mintingPaused: true, updatedBy: req.user!.id },
});
res.json({ message: "Minting paused" });
res.json({ message: "Paused" });
}),
);
router.post(
"/resume",
authenticate,
@@ -67,10 +64,9 @@ router.post(
where: { id: "default" },
data: { mintingPaused: false, updatedBy: req.user!.id },
});
res.json({ message: "Minting resumed" });
res.json({ message: "Resumed" });
}),
);
router.get(
"/users",
authenticate,
@@ -80,15 +76,12 @@ router.get(
const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 100);
const skip = (pageNum - 1) * limitNum;
const where: any = {};
if (search) {
if (search)
where.OR = [
{ plexUsername: { contains: search as string, mode: "insensitive" } },
{ email: { contains: search as string, mode: "insensitive" } },
];
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
@@ -102,7 +95,6 @@ router.get(
email: true,
isAdmin: true,
isActive: true,
walletAddress: true,
totalEarned: true,
totalSpent: true,
watchTimeMinutes: true,
@@ -111,7 +103,6 @@ router.get(
}),
prisma.user.count({ where }),
]);
res.json({
users,
pagination: {
@@ -123,7 +114,6 @@ router.get(
});
}),
);
router.put(
"/users/:id",
authenticate,
@@ -137,7 +127,6 @@ router.put(
res.json(user);
}),
);
router.post(
"/users/:id/bonus",
authenticate,
@@ -146,10 +135,8 @@ router.post(
const { amount, reason } = req.body;
if (!amount || amount <= 0)
return res.status(400).json({ error: "Valid amount required" });
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) return res.status(404).json({ error: "User not found" });
const transaction = await prisma.transaction.create({
data: {
userId: user.id,
@@ -159,7 +146,6 @@ router.post(
contentTitle: reason || "Admin bonus",
},
});
await prisma.user.update({
where: { id: user.id },
data: { totalEarned: { increment: amount } },
@@ -172,7 +158,6 @@ router.post(
res.json({ success: true, amount, transaction });
}),
);
router.post(
"/users/:id/adjust",
authenticate,
@@ -181,10 +166,8 @@ router.post(
const { amount, reason } = req.body;
if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0)
return res.status(400).json({ error: "Valid amount required" });
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) return res.status(404).json({ error: "User not found" });
const delta = Number(amount);
await prisma.transaction.create({
data: {
@@ -195,7 +178,6 @@ router.post(
contentTitle: reason || "Admin adjustment",
},
});
await prisma.user.update({
where: { id: user.id },
data:
@@ -203,11 +185,9 @@ router.post(
? { totalEarned: { increment: delta } }
: { totalSpent: { increment: Math.abs(delta) } },
});
res.json({ success: true });
}),
);
router.get(
"/analytics",
authenticate,
@@ -217,15 +197,13 @@ router.get(
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [userStats, transactionStats, watchStats, dailyActivity] =
await Promise.all([
prisma.$queryRaw`SELECT COUNT(*) as total, COUNT(CASE WHEN wallet_address IS NOT NULL THEN 1 END) as with_wallet, COUNT(CASE WHEN created_at >= ${sevenDaysAgo} THEN 1 END) as new_this_week FROM users`,
prisma.$queryRaw`SELECT COUNT(*) as total, COUNT(CASE WHEN created_at >= ${sevenDaysAgo} THEN 1 END) as new_this_week FROM users`,
prisma.$queryRaw`SELECT SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as total_earned, SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as total_spent, COUNT(*) as total_transactions FROM transactions`,
prisma.$queryRaw`SELECT SUM(duration) as total_seconds, SUM(credits_earned) as total_credits, COUNT(*) as total_events FROM watch_events WHERE is_processed = true`,
prisma.$queryRaw`SELECT DATE(created_at) as date, COUNT(DISTINCT user_id) as active_users, SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned, SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent FROM transactions WHERE created_at >= ${thirtyDaysAgo} GROUP BY DATE(created_at) ORDER BY date DESC LIMIT 30`,
]);
res.json({
users: userStats[0],
transactions: transactionStats[0],
@@ -234,5 +212,4 @@ router.get(
});
}),
);
export { router as adminRouter };
+22 -96
View File
@@ -6,24 +6,18 @@ import { asyncHandler } from "../middleware/errorHandler";
import { prisma } from "../utils/prisma";
const router = Router();
const OVERSEER_URL = process.env.OVERSEER_URL || "";
const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || "";
const overseerClient = axios.create({
baseURL: `${OVERSEER_URL}/api/v1`,
headers: {
"X-Api-Key": OVERSEER_API_KEY,
},
headers: { "X-Api-Key": OVERSEER_API_KEY },
});
// Get request costs
router.get(
"/costs",
authenticate,
asyncHandler(async (_req: AuthenticatedRequest, res) => {
const settings = await prisma.systemSettings.findFirst();
res.json({
movie: settings?.movieRequestCost || 500,
tv: settings?.tvRequestCost || 1000,
@@ -31,27 +25,20 @@ router.get(
});
}),
);
// Get user's balance and request availability
router.get(
"/balance",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
});
if (!user?.walletAddress) {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user)
return res.json({
hasWallet: false,
balance: 0,
canRequest: false,
canRequestMovie: false,
canRequestTV: false,
});
}
const settings = await prisma.systemSettings.findFirst();
const dbBalance = user.totalEarned - user.totalSpent;
res.json({
hasWallet: true,
balance: dbBalance,
@@ -64,76 +51,44 @@ router.get(
});
}),
);
// Search for content
router.get(
"/search",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { query } = req.query;
if (!query) {
return res.status(400).json({ error: "Query required" });
}
const response = await overseerClient.get("/search", {
params: { query },
});
if (!query) return res.status(400).json({ error: "Query required" });
const response = await overseerClient.get("/search", { params: { query } });
res.json(response.data);
}),
);
// Request content
router.post(
"/request",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { mediaType, mediaId, title, seasons } = req.body;
if (!mediaType || !mediaId || !title) {
if (!mediaType || !mediaId || !title)
return res.status(400).json({ error: "Missing required fields" });
}
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
});
if (!user?.walletAddress) {
return res.status(400).json({ error: "Wallet required" });
}
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user) return res.status(400).json({ error: "User required" });
const settings = await prisma.systemSettings.findFirst();
// Calculate cost
let cost = 0;
if (mediaType === "movie") {
cost = settings?.movieRequestCost || 500;
} else if (mediaType === "tv") {
cost = settings?.tvRequestCost || 1000;
if (seasons && seasons.length > 1) {
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250);
}
}
// Check balance
let cost =
mediaType === "movie"
? settings?.movieRequestCost || 500
: settings?.tvRequestCost || 1000;
if (mediaType === "tv" && seasons && seasons.length > 1)
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250);
const dbBalance = user.totalEarned - user.totalSpent;
if (dbBalance < cost) {
if (dbBalance < cost)
return res.status(400).json({
error: "Insufficient balance",
required: cost,
current: dbBalance,
});
}
// Create request in Overseer first
const overseerRequest = await overseerClient.post("/request", {
mediaType,
mediaId,
...(seasons && { seasons }),
});
// Create local request record
const request = await prisma.contentRequest.create({
data: {
userId: user.id,
@@ -146,8 +101,6 @@ router.post(
requestedAt: new Date(),
},
});
// Deduct credits immediately to prevent race condition
const transaction = await prisma.transaction.create({
data: {
userId: user.id,
@@ -158,21 +111,15 @@ router.post(
contentTitle: title,
},
});
await prisma.user.update({
where: { id: user.id },
data: {
totalSpent: { increment: cost },
},
data: { totalSpent: { increment: cost } },
});
// Emit real-time spend update
io.to(`user:${user.id}`).emit("credits_spent", {
amount: cost,
title,
transaction,
});
res.json({
success: true,
request,
@@ -181,38 +128,23 @@ router.post(
});
}),
);
// Webhook: Handle Overseer request status changes
router.post(
"/webhook",
asyncHandler(async (req, res) => {
const { request_id, status } = req.body;
if (!request_id || !status) {
if (!request_id || !status)
return res.status(400).json({ error: "Missing fields" });
}
// Find local request
const request = await prisma.contentRequest.findFirst({
where: { overseerRequestId: request_id },
include: { user: true },
});
if (!request) {
return res.status(404).json({ error: "Request not found" });
}
// Update status
if (!request) return res.status(404).json({ error: "Request not found" });
const updatedRequest = await prisma.contentRequest.update({
where: { id: request.id },
data: { status },
});
// Credits were already deducted at request time.
// On approval: just confirm and link transaction to request.
// On decline: refund credits back to user.
if (status === "DECLINED") {
const refundTransaction = await prisma.transaction.create({
await prisma.transaction.create({
data: {
userId: request.userId,
type: "ADJUSTMENT",
@@ -221,21 +153,15 @@ router.post(
contentTitle: request.title,
},
});
await prisma.user.update({
where: { id: request.userId },
data: {
totalSpent: { decrement: request.creditsCost },
},
data: { totalSpent: { decrement: request.creditsCost } },
});
io.to(`user:${request.userId}`).emit("bonus_received", {
amount: request.creditsCost,
reason: `Refunded: ${request.title}`,
transaction: refundTransaction,
});
}
res.json({ success: true, request: updatedRequest });
}),
);
+141 -183
View File
@@ -1,193 +1,151 @@
import { Router } from 'express';
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
import { prisma } from '../utils/prisma';
import { asyncHandler } from '../middleware/errorHandler';
import { Router } from "express";
import {
type AuthenticatedRequest,
authenticate,
requireAdmin,
} from "../middleware/auth";
import { asyncHandler } from "../middleware/errorHandler";
import { prisma } from "../utils/prisma";
const router = Router();
// Get user's transactions
router.get('/',
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = '1', limit = '20', type } = req.query;
const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 100);
const skip = (pageNum - 1) * limitNum;
const where: any = { userId: req.user!.id };
if (type) {
where.type = type;
}
const [transactions, total] = await Promise.all([
prisma.transaction.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limitNum,
include: {
watchEvent: {
select: {
duration: true,
percentComplete: true
}
},
request: {
select: {
mediaType: true,
status: true
}
}
}
}),
prisma.transaction.count({ where })
]);
res.json({
transactions,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
})
router.get(
"/",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = "1", limit = "20", type } = req.query;
const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 100);
const skip = (pageNum - 1) * limitNum;
const where: any = { userId: req.user!.id };
if (type) where.type = type;
const [transactions, total] = await Promise.all([
prisma.transaction.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: limitNum,
include: {
watchEvent: { select: { duration: true, percentComplete: true } },
request: { select: { mediaType: true, status: true } },
},
}),
prisma.transaction.count({ where }),
]);
res.json({
transactions,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
}),
);
// Get transaction stats
router.get('/stats',
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [
totalStats,
recentStats,
byType
] = await Promise.all([
// All time stats
prisma.transaction.groupBy({
by: ['type'],
where: { userId: req.user!.id },
_sum: { amount: true },
_count: { id: true }
}),
// Last 30 days
prisma.transaction.groupBy({
by: ['type'],
where: {
userId: req.user!.id,
createdAt: { gte: thirtyDaysAgo }
},
_sum: { amount: true },
_count: { id: true }
}),
// By type breakdown
prisma.transaction.findMany({
where: { userId: req.user!.id },
select: {
type: true,
amount: true,
createdAt: true
},
orderBy: { createdAt: 'desc' },
take: 100
})
]);
// Calculate daily earnings for chart
const dailyEarnings = await prisma.$queryRaw`
SELECT
DATE(created_at) as date,
SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned,
SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent
FROM transactions
WHERE user_id = ${req.user!.id}
AND created_at >= ${thirtyDaysAgo}
GROUP BY DATE(created_at)
ORDER BY date DESC
`;
res.json({
total: totalStats,
recent: recentStats,
dailyEarnings,
recentTransactions: byType.slice(0, 10)
});
})
router.get(
"/stats",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [totalStats, recentStats, byType] = await Promise.all([
prisma.transaction.groupBy({
by: ["type"],
where: { userId: req.user!.id },
_sum: { amount: true },
_count: { id: true },
}),
prisma.transaction.groupBy({
by: ["type"],
where: { userId: req.user!.id, createdAt: { gte: thirtyDaysAgo } },
_sum: { amount: true },
_count: { id: true },
}),
prisma.transaction.findMany({
where: { userId: req.user!.id },
select: { type: true, amount: true, createdAt: true },
orderBy: { createdAt: "desc" },
take: 100,
}),
]);
const dailyEarnings =
await prisma.$queryRaw`SELECT DATE(created_at) as date, SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned, SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent FROM transactions WHERE user_id = ${req.user!.id} AND created_at >= ${thirtyDaysAgo} GROUP BY DATE(created_at) ORDER BY date DESC`;
res.json({
total: totalStats,
recent: recentStats,
dailyEarnings,
recentTransactions: byType.slice(0, 10),
});
}),
);
// Admin: Get all transactions
router.get('/admin/all',
authenticate,
requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = '1', limit = '50', userId, type } = req.query;
const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 100);
const skip = (pageNum - 1) * limitNum;
const where: any = {};
if (userId) where.userId = userId;
if (type) where.type = type;
const [transactions, total] = await Promise.all([
prisma.transaction.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limitNum,
include: {
user: {
select: {
plexUsername: true,
walletAddress: true
}
}
}
}),
prisma.transaction.count({ where })
]);
res.json({
transactions,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
})
router.get(
"/admin/all",
authenticate,
requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = "1", limit = "50", userId, type } = req.query;
const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 100);
const skip = (pageNum - 1) * limitNum;
const where: any = {};
if (userId) where.userId = userId;
if (type) where.type = type;
const [transactions, total] = await Promise.all([
prisma.transaction.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: limitNum,
include: { user: { select: { plexUsername: true } } },
}),
prisma.transaction.count({ where }),
]);
res.json({
transactions,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
}),
);
// Get system-wide stats
router.get('/admin/stats',
// ... (keeping existing code)
router.get(
"/admin/stats",
authenticate,
requireAdmin,
asyncHandler(async (_req: AuthenticatedRequest, res) => {
const [total, recent] = await Promise.all([
prisma.transaction.groupBy({
by: ["type"],
_sum: { amount: true },
_count: { id: true },
}),
prisma.transaction.groupBy({
by: ["type"],
where: {
createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
},
_sum: { amount: true },
_count: { id: true },
}),
]);
res.json({ total, recent });
}),
);
// Get recent global activity
router.get('/recent',
authenticate,
asyncHandler(async (_req: AuthenticatedRequest, res) => {
const transactions = await prisma.transaction.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
include: {
user: {
select: {
plexUsername: true
}
}
}
});
res.json({ transactions });
})
router.get(
"/recent",
authenticate,
asyncHandler(async (_req: AuthenticatedRequest, res) => {
const transactions = await prisma.transaction.findMany({
orderBy: { createdAt: "desc" },
take: 10,
include: { user: { select: { plexUsername: true } } },
});
res.json({ transactions });
}),
);
export { router as transactionsRouter };
+130 -155
View File
@@ -1,165 +1,140 @@
import { Router } from 'express';
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
import { prisma } from '../utils/prisma';
import { asyncHandler } from '../middleware/errorHandler';
import { Router } from "express";
import { type AuthenticatedRequest, authenticate } from "../middleware/auth";
import { asyncHandler } from "../middleware/errorHandler";
import { prisma } from "../utils/prisma";
const router = Router();
// Get current user profile
router.get('/me',
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
plexId: true,
plexUsername: true,
email: true,
isAdmin: true,
walletAddress: true,
totalEarned: true,
totalSpent: true,
watchTimeMinutes: true,
createdAt: true
}
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
})
router.get(
"/me",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
plexId: true,
plexUsername: true,
email: true,
isAdmin: true,
totalEarned: true,
totalSpent: true,
watchTimeMinutes: true,
createdAt: true,
},
});
if (!user) return res.status(404).json({ error: "User not found" });
res.json(user);
}),
);
// Update user profile
router.put('/me',
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { email } = req.body;
const user = await prisma.user.update({
where: { id: req.user!.id },
data: { email },
select: {
id: true,
plexUsername: true,
email: true,
walletAddress: true
}
});
res.json(user);
})
router.put(
"/me",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { email } = req.body;
const user = await prisma.user.update({
where: { id: req.user!.id },
data: { email },
select: { id: true, plexUsername: true, email: true },
});
res.json(user);
}),
);
// Get user's watch history
router.get('/me/watch-history',
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = '1', limit = '20' } = req.query;
const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 50);
const skip = (pageNum - 1) * limitNum;
const [events, total] = await Promise.all([
prisma.watchEvent.findMany({
where: { userId: req.user!.id },
orderBy: { watchedAt: 'desc' },
skip,
take: limitNum,
select: {
id: true,
contentType: true,
title: true,
grandparentTitle: true,
duration: true,
percentComplete: true,
creditsEarned: true,
isProcessed: true,
watchedAt: true
}
}),
prisma.watchEvent.count({ where: { userId: req.user!.id } })
]);
res.json({
events,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
})
router.get(
"/me/watch-history",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = "1", limit = "20" } = req.query;
const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 50);
const skip = (pageNum - 1) * limitNum;
const [events, total] = await Promise.all([
prisma.watchEvent.findMany({
where: { userId: req.user!.id },
orderBy: { watchedAt: "desc" },
skip,
take: limitNum,
select: {
id: true,
contentType: true,
title: true,
grandparentTitle: true,
duration: true,
percentComplete: true,
creditsEarned: true,
isProcessed: true,
watchedAt: true,
},
}),
prisma.watchEvent.count({ where: { userId: req.user!.id } }),
]);
res.json({
events,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
}),
);
// Get user's content requests
router.get('/me/requests',
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = '1', limit = '20', status } = req.query;
const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 50);
const skip = (pageNum - 1) * limitNum;
const where: any = { userId: req.user!.id };
if (status) where.status = status;
const [requests, total] = await Promise.all([
prisma.contentRequest.findMany({
where,
orderBy: { requestedAt: 'desc' },
skip,
take: limitNum
}),
prisma.contentRequest.count({ where })
]);
res.json({
requests,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
})
router.get(
"/me/requests",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = "1", limit = "20", status } = req.query;
const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 50);
const skip = (pageNum - 1) * limitNum;
const where: any = { userId: req.user!.id };
if (status) where.status = status;
const [requests, total] = await Promise.all([
prisma.contentRequest.findMany({
where,
orderBy: { requestedAt: "desc" },
skip,
take: limitNum,
}),
prisma.contentRequest.count({ where }),
]);
res.json({
requests,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
}),
);
// 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 });
})
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 };
+3 -27
View File
@@ -21,55 +21,37 @@ router.post(
asyncHandler(async (req, res) => {
const webhookSignature = req.headers["x-tautulli-signature"] as string;
const payload = JSON.stringify(req.body);
if (
WEBHOOK_SECRET &&
webhookSignature &&
!verifyWebhookSignature(payload, webhookSignature)
) {
)
return res.status(401).json({ error: "Invalid signature" });
}
const event = req.body;
if (event.action !== "watched")
return res.json({ message: "Event type not processed" });
if (!event.user_id || !event.rating_key || !event.session_key)
return res.status(400).json({ error: "Missing required fields" });
const user = await prisma.user.findUnique({
where: { plexId: event.user_id.toString() },
});
if (!user) return res.status(404).json({ error: "User not found" });
if (!user.walletAddress)
return res.status(400).json({ error: "User has no wallet" });
const existing = await prisma.watchEvent.findUnique({
where: { sessionId: event.session_key.toString() },
});
if (existing) return res.json({ message: "Event already processed" });
const settings = await prisma.systemSettings.findFirst();
const creditsPerMinute = settings?.creditsPerMinute || 2;
const minWatchPercent = settings?.minWatchPercent || 80;
const minWatchMinutes = settings?.minWatchMinutes || 5;
const watchDurationMinutes = Math.floor(
(event.stopped - event.started) / 60,
);
const percentComplete = event.percent_complete || 0;
if (percentComplete < minWatchPercent)
return res.json({
message: "Watch percentage too low",
percentComplete,
required: minWatchPercent,
});
return res.json({ message: "Watch percentage too low" });
if (watchDurationMinutes < minWatchMinutes)
return res.json({
message: "Watch duration too short",
watchDurationMinutes,
required: minWatchMinutes,
});
return res.json({ message: "Watch duration too short" });
let creditsEarned = watchDurationMinutes * creditsPerMinute;
if (settings?.newReleaseMultiplier && event.is_new)
creditsEarned = Math.floor(
@@ -79,7 +61,6 @@ router.post(
creditsEarned = Math.floor(
creditsEarned * Number(settings.bonusMultiplier),
);
const watchEvent = await prisma.watchEvent.create({
data: {
userId: user.id,
@@ -94,7 +75,6 @@ router.post(
watchedAt: new Date(event.stopped * 1000),
},
});
const transaction = await prisma.transaction.create({
data: {
userId: user.id,
@@ -105,7 +85,6 @@ router.post(
contentTitle: event.title,
},
});
await prisma.user.update({
where: { id: user.id },
data: {
@@ -113,12 +92,10 @@ router.post(
watchTimeMinutes: { increment: watchDurationMinutes },
},
});
await prisma.watchEvent.update({
where: { id: watchEvent.id },
data: { isProcessed: true },
});
io.to(`user:${user.id}`).emit("credits_earned", {
amount: creditsEarned,
title: event.title,
@@ -130,7 +107,6 @@ router.post(
createdAt: transaction.createdAt,
},
});
res.json({
success: true,
creditsEarned,
@@ -1,108 +1 @@
'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>
);
}
export function ActivityFeed() { return null; }
@@ -1,162 +0,0 @@
'use client';
import { useState } from 'react';
import { walletApi } 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 { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Copy, Check, AlertTriangle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
interface CreateWalletModalProps {
open: boolean;
onClose: () => void;
onCreated: () => void;
}
export function CreateWalletModal({ open, onClose, onCreated }: CreateWalletModalProps) {
const [step, setStep] = useState<'create' | 'backup' | 'success'>('create');
const [privateKey, setPrivateKey] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const handleCreate = async () => {
setIsLoading(true);
try {
const response = await walletApi.createWallet();
setPrivateKey(response.data.privateKey || '');
setStep('backup');
toast.success('Wallet created successfully!');
onCreated();
} catch (error) {
toast.error('Failed to create wallet');
} finally {
setIsLoading(false);
}
};
const handleBackup = async () => {
try {
const response = await walletApi.backupWallet();
setPrivateKey(response.data.privateKey);
setStep('backup');
} catch (error) {
toast.error('Failed to get backup');
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(privateKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success('Copied to clipboard');
};
const handleClose = () => {
setStep('create');
setPrivateKey('');
setCopied(false);
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'create' && 'Create Wallet'}
{step === 'backup' && 'Backup Your Wallet'}
{step === 'success' && 'Wallet Ready!'}
</DialogTitle>
<DialogDescription>
{step === 'create' && 'Create a new Solana wallet to store your $COOP tokens'}
{step === 'backup' && 'Save this private key securely. You will need it to recover your wallet.'}
{step === 'success' && 'Your wallet is ready to use!'}
</DialogDescription>
</DialogHeader>
{step === 'create' && (
<div className="space-y-4">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
You will be shown a private key. Store it securely - it cannot be recovered!
</AlertDescription>
</Alert>
<Button onClick={handleCreate} disabled={isLoading} className="w-full">
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
'Create New Wallet'
)}
</Button>
<div className="text-center">
<span className="text-sm text-muted-foreground">or</span>
</div>
<Button variant="outline" onClick={handleBackup} className="w-full">
Show Existing Backup
</Button>
</div>
)}
{step === 'backup' && (
<div className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Never share this private key with anyone. Store it in a secure password manager.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label>Private Key</Label>
<div className="flex gap-2">
<Input
type="password"
value={privateKey}
readOnly
className="font-mono"
/>
<Button
size="icon"
variant="outline"
onClick={copyToClipboard}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<Button onClick={() => setStep('success')} className="w-full">
I have saved my private key
</Button>
</div>
)}
{step === 'success' && (
<div className="space-y-4 text-center">
<div className="mx-auto w-12 h-12 bg-green-500/10 rounded-full flex items-center justify-center">
<Check className="h-6 w-6 text-green-500" />
</div>
<p className="text-muted-foreground">
Your wallet has been created and funded with 2 SOL for transaction fees.
Start watching content on Plex to earn $COOP!
</p>
<Button onClick={handleClose} className="w-full">
Start Earning
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -1,144 +1 @@
"use client";
import { Clock, Coins, Medal, Star, Trophy } from "lucide-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { userApi } from "@/lib/api";
import { formatDuration, formatNumber } from "@/lib/utils";
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-muted-foreground" />;
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>
);
}
export function Leaderboard() { return null; }
@@ -1,120 +1 @@
'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>
);
}
export function RequestHistory() { return null; }
@@ -1,222 +0,0 @@
'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>
);
}
@@ -1,139 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { transactionApi } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatNumber, formatDate } from '@/lib/utils';
import { TrendingUp, TrendingDown, Gift, ArrowRightLeft } from 'lucide-react';
interface Transaction {
id: string;
type: 'EARN' | 'SPEND' | 'BONUS' | 'ADJUSTMENT';
amount: number;
description: string | null;
contentTitle: string | null;
createdAt: string;
solanaSignature: string | null;
}
export function TransactionList() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadTransactions();
}, []);
const loadTransactions = async () => {
try {
const response = await transactionApi.getTransactions();
setTransactions(response.data.transactions);
} catch (error) {
console.error('Failed to load transactions:', error);
} finally {
setIsLoading(false);
}
};
const getTransactionIcon = (type: string) => {
switch (type) {
case 'EARN':
return <TrendingUp className="h-4 w-4 text-green-500" />;
case 'SPEND':
return <TrendingDown className="h-4 w-4 text-red-500" />;
case 'BONUS':
return <Gift className="h-4 w-4 text-purple-500" />;
default:
return <ArrowRightLeft className="h-4 w-4 text-gray-500" />;
}
};
const getTransactionColor = (type: string) => {
switch (type) {
case 'EARN':
return 'bg-green-500/10 text-green-500';
case 'SPEND':
return 'bg-red-500/10 text-red-500';
case 'BONUS':
return 'bg-purple-500/10 text-purple-500';
default:
return 'bg-gray-500/10 text-gray-500';
}
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Loading transactions...
</CardContent>
</Card>
);
}
if (transactions.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No transactions yet. Start watching content to earn $COOP!
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{transactions.map((tx) => (
<div
key={tx.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 ${getTransactionColor(tx.type)}`}>
{getTransactionIcon(tx.type)}
</div>
<div>
<p className="font-medium">
{tx.contentTitle || tx.description || tx.type}
</p>
<p className="text-sm text-muted-foreground">
{formatDate(tx.createdAt)}
</p>
{tx.solanaSignature && (
<a
href={`https://explorer.solana.com/tx/${tx.solanaSignature}?cluster=devnet`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 hover:underline"
>
View on Explorer
</a>
)}
</div>
</div>
<div className="text-right">
<p className={`font-bold ${
tx.type === 'EARN' || tx.type === 'BONUS'
? 'text-green-500'
: 'text-red-500'
}`}>
{tx.type === 'EARN' || tx.type === 'BONUS' ? '+' : '-'}
{formatNumber(tx.amount)} $COOP
</p>
<Badge variant="outline" className="text-xs">
{tx.type}
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -1,123 +1 @@
'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 { formatDuration, formatDate, formatNumber } from '@/lib/utils';
import { Film, Tv, CheckCircle, XCircle, Clock } from 'lucide-react';
interface WatchEvent {
id: string;
contentType: string;
title: string;
grandparentTitle: string | null;
duration: number;
percentComplete: number;
creditsEarned: number;
isProcessed: boolean;
watchedAt: string;
}
export function WatchHistory() {
const [events, setEvents] = useState<WatchEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadHistory();
}, []);
const loadHistory = async () => {
try {
const response = await userApi.getWatchHistory();
setEvents(response.data.events);
} catch (error) {
console.error('Failed to load watch history:', error);
} finally {
setIsLoading(false);
}
};
const getContentIcon = (type: string) => {
return type === 'movie' ? (
<Film className="h-4 w-4" />
) : (
<Tv className="h-4 w-4" />
);
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Loading watch history...
</CardContent>
</Card>
);
}
if (events.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No watch history yet. Start watching on Plex!
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Watch History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{events.map((event) => (
<div
key={event.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">
{getContentIcon(event.contentType)}
</div>
<div>
<p className="font-medium">{event.title}</p>
{event.grandparentTitle && (
<p className="text-sm text-muted-foreground">
{event.grandparentTitle}
</p>
)}
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDuration(event.duration)}
</span>
<span>{event.percentComplete}% watched</span>
<span>{formatDate(event.watchedAt)}</span>
</div>
</div>
</div>
<div className="text-right">
{event.isProcessed ? (
<div className="flex items-center gap-2 text-green-500">
<CheckCircle className="h-4 w-4" />
<span className="font-bold">
+{formatNumber(event.creditsEarned)} $COOP
</span>
</div>
) : (
<div className="flex items-center gap-2 text-yellow-500">
<XCircle className="h-4 w-4" />
<span className="text-sm">Pending</span>
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
export function WatchHistory() { return null; }
@@ -1,116 +0,0 @@
"use client";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import {
Egg,
ExternalLink,
ShieldCheck,
Sparkles,
Wallet,
Zap,
} from "lucide-react";
import { useEffect } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { walletApi } from "@/lib/api";
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-2 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="font-display text-3xl tracking-tight">
Welcome to the Coop!
</h2>
<p className="text-muted-foreground text-lg">
You are one step away from earning golden eggs 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 &ldquo;Create Managed Wallet&rdquo; for an easy start, or
&ldquo;Select Wallet&rdquo; 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">
<Egg className="h-32 w-32 text-primary opacity-20 animate-pulse" />
</div>
</div>
</CardContent>
</Card>
);
}
+66 -253
View File
@@ -1,20 +1,8 @@
"use client";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import {
Clock,
Egg,
ExternalLink,
Film,
History,
TrendingDown,
TrendingUp
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -23,33 +11,26 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { overseerApi, transactionApi } from "@/lib/api";
import { overseerApi } from "@/lib/api";
import { useSocket } from "@/lib/socket";
import { useStore } from "@/lib/store";
import { formatNumber, truncateAddress } from "@/lib/utils";
import { formatNumber } from "@/lib/utils";
import { ActivityFeed } from "./components/ActivityFeed";
import { CreateWalletModal } from "./components/CreateWalletModal";
import { Leaderboard } from "./components/Leaderboard";
import { RequestHistory } from "./components/RequestHistory";
import { SearchRequestModal } from "./components/SearchRequestModal";
import { TransactionList } from "./components/TransactionList";
import { WatchHistory } from "./components/WatchHistory";
import { WelcomeOnboarding } from "./components/WelcomeOnboarding";
interface WalletData {
hasWallet: boolean;
address?: string;
balance: number;
totalEarned: number;
totalSpent: number;
explorerUrl?: string;
}
export default function DashboardPage() {
const router = useRouter();
const { user, isAuthenticated, logout } = useStore();
const { isAuthenticated } = useStore();
const [wallet, setWallet] = useState<WalletData | null>(null);
const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 }); const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [requestCosts, setRequestCosts] = useState({ movie: 500, tv: 1000 });
const [isLoading, setIsLoading] = useState(true);
const socket = useSocket();
@@ -58,258 +39,90 @@ export default function DashboardPage() {
router.push("/login");
return;
}
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");
};
}
if (!socket) return;
const refresh = () => loadData();
socket.on("credits_earned", refresh);
socket.on("bonus_received", refresh);
socket.on("credits_spent", refresh);
return () => {
socket.off("credits_earned", refresh);
socket.off("bonus_received", refresh);
socket.off("credits_spent", refresh);
};
}, [socket]);
const loadData = async () => {
try {
const [walletRes, costsRes] = await Promise.all([
walletApi.getWallet(),
overseerApi.getCosts().catch(() => ({ data: { movie: 100, tv: 200 } })),
fetch("/api/wallet").then((r) => r.json()),
overseerApi.getCosts(),
]);
setWallet(walletRes.data);
setWallet(walletRes);
setRequestCosts(costsRes.data);
} catch (error) {
toast.error("Failed to load wallet data");
} catch {
toast.error("Failed to load dashboard");
} finally {
setIsLoading(false);
}
};
if (!isAuthenticated || !user) {
return null;
}
if (isLoading) return <div className="p-8">Loading...</div>;
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b-2 bg-card">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Egg className="w-6 h-6 text-primary" />
<h1 className="font-display text-2xl">The Coop</h1>
<div className="p-8 space-y-6">
<Card>
<CardHeader>
<CardTitle>Coop</CardTitle>
<CardDescription>DB-only credits</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3">
<div>
<div className="text-sm text-muted-foreground">Nest Egg</div>
<div className="text-3xl font-bold">
{formatNumber(wallet?.balance || 0)} $COOP
</div>
</div>
<Badge variant="secondary">{user.plexUsername}</Badge>
{user.isAdmin && (
<Button
variant="outline"
size="sm"
onClick={() => router.push("/admin")}
>
Admin
</Button>
)}
</div>
<div className="flex items-center gap-4">
<div className="wallet-adapter-custom-wrapper hidden md:block"> </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 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card className="border-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Nest Egg</CardTitle>
<Wallet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{wallet ? formatNumber(wallet.balance) : "--"} $COOP
</div>
<p className="text-xs text-muted-foreground">
{wallet?.hasWallet
? "Ready to spend"
: "Create wallet to start"}
</p>
</CardContent>
</Card>
<Card className="border-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
<div>
<div className="text-sm text-muted-foreground">
Total Gathered
</CardTitle>
<TrendingUp className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{wallet ? formatNumber(wallet.totalEarned) : "--"} $COOP
</div>
<p className="text-xs text-muted-foreground">
From watching content
</p>
</CardContent>
</Card>
<Card className="border-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Spent</CardTitle>
<TrendingDown className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{wallet ? formatNumber(wallet.totalSpent) : "--"} $COOP
<div className="text-3xl font-bold">
{formatNumber(wallet?.totalEarned || 0)}
</div>
<p className="text-xs text-muted-foreground">
On content requests
</p>
</CardContent>
</Card>
<Card
className="border-2 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 group-hover:text-primary transition-colors" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{requestCosts.movie} $COOP
</div>
<div>
<div className="text-sm text-muted-foreground">Spent</div>
<div className="text-3xl font-bold">
{formatNumber(wallet?.totalSpent || 0)}
</div>
<p className="text-xs text-muted-foreground">
Click to request content
</p>
</CardContent>
</Card>
</div>
{/* Wallet Section */}
{wallet?.hasWallet && (
<Card className="mb-8 border-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wallet className="h-5 w-5" />
Your Coop Wallet
</CardTitle>
<CardDescription>
Manage your Solana wallet and $COOP tokens
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div>
<p className="text-sm text-muted-foreground">Address</p>
<p className="font-mono font-medium">
{truncateAddress(wallet.address!)}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsCreateModalOpen(true)}
>
<History className="mr-2 h-4 w-4" />
Backup
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={wallet.explorerUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
Explorer
</a>
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Tabs */}
<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 className="border-2">
<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>
<TabsContent value="requests">
<RequestHistory />
</TabsContent>
</Tabs>
</div>
</div>
<div className="lg:col-span-1 space-y-8">
<ActivityFeed />
<Leaderboard />
</div>
</div>
</main>
<CreateWalletModal
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onCreated={loadData}
/>
<SearchRequestModal
open={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
onRequested={loadData}
costs={requestCosts}
balance={wallet?.balance || 0}
/>
</CardContent>
</Card>
<div className="text-sm text-muted-foreground">
Request costs: movie {requestCosts.movie}, tv {requestCosts.tv}
</div>
<Tabs defaultValue="history">
<TabsList>
<TabsTrigger value="history">History</TabsTrigger>
<TabsTrigger value="requests">Requests</TabsTrigger>
<TabsTrigger value="leaderboard">Leaderboard</TabsTrigger>
</TabsList>
<TabsContent value="history">
<WatchHistory />
</TabsContent>
<TabsContent value="requests">
<RequestHistory />
</TabsContent>
<TabsContent value="leaderboard">
<Leaderboard />
</TabsContent>
</Tabs>
<ActivityFeed />
</div>
);
}
+1 -60
View File
@@ -1,60 +1 @@
'use client';
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);
// Check for stored token on mount
const token = localStorage.getItem('token');
if (token) {
// Verify token and get user data
authApi.verify()
.then((res) => {
setUser(res.data.user);
setToken(token);
})
.catch(() => {
localStorage.removeItem('token');
});
}
}, [setUser, setToken]);
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{children}
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
</ThemeProvider>
);
}
export function Providers({ children }: { children: React.ReactNode }) { return <>{children}</>; }