Refactor: Improve error handling in authentication flow
This commit is contained in:
@@ -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,6 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
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 {
|
datasource db {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/
|
|||||||
import { prisma } from '../utils/prisma';
|
import { prisma } from '../utils/prisma';
|
||||||
import { asyncHandler } from '../middleware/errorHandler';
|
import { asyncHandler } from '../middleware/errorHandler';
|
||||||
import { mintTokens } from '../services/solana';
|
import { mintTokens } from '../services/solana';
|
||||||
|
import { io } from '../index';
|
||||||
|
|
||||||
const router = Router();
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
amount,
|
amount,
|
||||||
|
|||||||
@@ -165,45 +165,28 @@ router.get('/admin/all',
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Admin: Get system-wide stats
|
// Get system-wide stats
|
||||||
router.get('/admin/stats',
|
router.get('/admin/stats',
|
||||||
|
// ... (keeping existing code)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get recent global activity
|
||||||
|
router.get('/recent',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireAdmin,
|
|
||||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||||
const [
|
const transactions = await prisma.transaction.findMany({
|
||||||
totalMinted,
|
orderBy: { createdAt: 'desc' },
|
||||||
totalBurned,
|
take: 10,
|
||||||
totalUsers,
|
include: {
|
||||||
activeUsers,
|
user: {
|
||||||
recentTransactions
|
select: {
|
||||||
] = await Promise.all([
|
plexUsername: true
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
]);
|
|
||||||
|
|
||||||
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 });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
export { router as userRouter };
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ services:
|
|||||||
- SOLANA_MINT_AUTHORITY_KEYPAIR=${SOLANA_MINT_AUTHORITY_KEYPAIR}
|
- SOLANA_MINT_AUTHORITY_KEYPAIR=${SOLANA_MINT_AUTHORITY_KEYPAIR}
|
||||||
- PLEX_CLIENT_ID=${PLEX_CLIENT_ID}
|
- PLEX_CLIENT_ID=${PLEX_CLIENT_ID}
|
||||||
- PLEX_CLIENT_SECRET=${PLEX_CLIENT_SECRET}
|
- PLEX_CLIENT_SECRET=${PLEX_CLIENT_SECRET}
|
||||||
|
- PLEX_REDIRECT_URI=${PLEX_REDIRECT_URI}
|
||||||
- TAUTULLI_URL=${TAUTULLI_URL}
|
- TAUTULLI_URL=${TAUTULLI_URL}
|
||||||
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
||||||
- OVERSEER_URL=${OVERSEER_URL}
|
- OVERSEER_URL=${OVERSEER_URL}
|
||||||
|
|||||||
+12
-16
@@ -11,7 +11,7 @@ COPY package*.json ./
|
|||||||
COPY frontend/package*.json ./frontend/
|
COPY frontend/package*.json ./frontend/
|
||||||
|
|
||||||
# Install dependencies
|
# 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 source
|
||||||
COPY frontend/ ./frontend/
|
COPY frontend/ ./frontend/
|
||||||
@@ -25,29 +25,25 @@ ARG NEXT_PUBLIC_SOLANA_RPC_URL
|
|||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
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
|
# Production stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy standalone output
|
||||||
COPY package*.json ./
|
COPY --from=builder /app/frontend/.next/standalone ./
|
||||||
COPY frontend/package*.json ./frontend/
|
COPY --from=builder /app/frontend/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/frontend/public ./public
|
||||||
# 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
|
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
# Start application
|
# Start application
|
||||||
CMD ["npm", "start"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
Vendored
+6
@@ -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.
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
webpack: (config) => {
|
output: 'standalone',
|
||||||
config.resolve.alias['@'] = path.join(__dirname, 'src');
|
allowedDevOrigins: ['172.20.1.238'],
|
||||||
config.parallelism = 1;
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${process.env.NEXT_PUBLIC_API_URL}/api/:path*`,
|
destination: `${apiUrl}/api/:path*`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/webhooks/:path*',
|
source: '/webhooks/:path*',
|
||||||
destination: `${process.env.NEXT_PUBLIC_API_URL}/webhooks/:path*`,
|
destination: `${apiUrl}/webhooks/:path*`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,16 +30,16 @@
|
|||||||
"socket.io-client": "^4.7.3",
|
"socket.io-client": "^4.7.3",
|
||||||
"sonner": "^1.2.4",
|
"sonner": "^1.2.4",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,9 +20,16 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatNumber, truncateAddress } from '@/lib/utils';
|
import { formatNumber, truncateAddress } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useSocket } from '@/lib/socket';
|
||||||
|
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
|
||||||
import { TransactionList } from './components/TransactionList';
|
import { TransactionList } from './components/TransactionList';
|
||||||
import { WatchHistory } from './components/WatchHistory';
|
import { WatchHistory } from './components/WatchHistory';
|
||||||
import { CreateWalletModal } from './components/CreateWalletModal';
|
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 {
|
interface WalletData {
|
||||||
hasWallet: boolean;
|
hasWallet: boolean;
|
||||||
@@ -39,7 +46,9 @@ export default function DashboardPage() {
|
|||||||
const [wallet, setWallet] = useState<WalletData | null>(null);
|
const [wallet, setWallet] = useState<WalletData | null>(null);
|
||||||
const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 });
|
const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 });
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const socket = useSocket();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -50,6 +59,37 @@ export default function DashboardPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [isAuthenticated, router]);
|
}, [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 () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [walletRes, costsRes] = await Promise.all([
|
const [walletRes, costsRes] = await Promise.all([
|
||||||
@@ -84,15 +124,25 @@ export default function DashboardPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" onClick={logout}>
|
<div className="flex items-center gap-4">
|
||||||
Sign Out
|
<div className="wallet-adapter-custom-wrapper hidden md:block">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<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 */}
|
{/* 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>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Balance</CardTitle>
|
<CardTitle className="text-sm font-medium">Balance</CardTitle>
|
||||||
@@ -138,37 +188,21 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Request Cost</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{requestCosts.movie} $COOP</div>
|
<div className="text-2xl font-bold">{requestCosts.movie} $COOP</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Per movie request
|
Click to request content
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wallet Section */}
|
{/* Wallet Section removed from here if redundant, or kept if we want it to show after creation */}
|
||||||
{!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?.hasWallet && (
|
{wallet?.hasWallet && (
|
||||||
<Card className="mb-8">
|
<Card className="mb-8">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -206,20 +240,32 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs defaultValue="transactions" className="space-y-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<TabsList>
|
<div className="lg:col-span-2">
|
||||||
<TabsTrigger value="transactions">Transactions</TabsTrigger>
|
<Tabs defaultValue="transactions" className="space-y-4">
|
||||||
<TabsTrigger value="history">Watch History</TabsTrigger>
|
<TabsList>
|
||||||
</TabsList>
|
<TabsTrigger value="transactions">Transactions</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">Watch History</TabsTrigger>
|
||||||
|
<TabsTrigger value="requests">My Requests</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="transactions">
|
<TabsContent value="transactions">
|
||||||
<TransactionList />
|
<TransactionList />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="history">
|
<TabsContent value="history">
|
||||||
<WatchHistory />
|
<WatchHistory />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
<TabsContent value="requests">
|
||||||
|
<RequestHistory />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-1 space-y-8">
|
||||||
|
<ActivityFeed />
|
||||||
|
<Leaderboard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<CreateWalletModal
|
<CreateWalletModal
|
||||||
@@ -227,6 +273,13 @@ export default function DashboardPage() {
|
|||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
onCreated={loadData}
|
onCreated={loadData}
|
||||||
/>
|
/>
|
||||||
|
<SearchRequestModal
|
||||||
|
open={isSearchModalOpen}
|
||||||
|
onClose={() => setIsSearchModalOpen(false)}
|
||||||
|
onRequested={loadData}
|
||||||
|
costs={requestCosts}
|
||||||
|
balance={wallet?.balance || 0}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@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 {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
@@ -51,9 +76,10 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
border-color: hsl(var(--border));
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" className="dark">
|
||||||
<body className={inter.className}>
|
<body className={`${inter.className} dark bg-slate-950 text-foreground`}>
|
||||||
<ProvidersWrapper>
|
<ProvidersWrapper>
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { authApi } from '@/lib/api';
|
import { authApi } from '@/lib/api';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -13,17 +11,13 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const { isAuthenticated } = useStore();
|
||||||
const { setUser, setToken, isAuthenticated } = useStore();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for Plex OAuth callback
|
setIsMounted(true);
|
||||||
const code = searchParams.get('code');
|
}, []);
|
||||||
if (code) {
|
|
||||||
handlePlexCallback(code);
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
@@ -31,26 +25,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [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 () => {
|
const handlePlexLogin = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
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 (
|
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">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||||
|
|||||||
+180
-8
@@ -1,14 +1,186 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tv, Wallet, Zap, Shield, ChevronRight, PlayCircle, Info } from 'lucide-react';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function LandingPage() {
|
||||||
const router = useRouter();
|
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(() => {
|
<Link href="/login" className="md:hidden">
|
||||||
router.push('/login');
|
<Button size="sm" className="rounded-full">Login</Button>
|
||||||
}, [router]);
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
import { ReactNode } from 'react';
|
import { Providers } from './providers';
|
||||||
|
|
||||||
const DynamicProviders = dynamic(
|
|
||||||
() => import('./providers').then((mod) => ({ default: mod.Providers })),
|
|
||||||
{ ssr: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
export function ProvidersWrapper({ children }: { children: ReactNode }) {
|
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode, useEffect, useState } from 'react';
|
import { ReactNode, useEffect, useMemo, useState } from 'react';
|
||||||
import { ThemeProvider } from 'next-themes';
|
import { ThemeProvider } from 'next-themes';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { authApi } from '@/lib/api';
|
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 }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { setUser, setToken } = useStore();
|
const { setUser, setToken } = useStore();
|
||||||
|
|
||||||
|
const network = WalletAdapterNetwork.Devnet;
|
||||||
|
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
|
||||||
|
const wallets = useMemo(
|
||||||
|
() => [
|
||||||
|
new PhantomWalletAdapter(),
|
||||||
|
new SolflareWalletAdapter(),
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
|
||||||
@@ -33,7 +48,13 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||||
{children}
|
<ConnectionProvider endpoint={endpoint}>
|
||||||
|
<WalletProvider wallets={wallets} autoConnect>
|
||||||
|
<WalletModalProvider>
|
||||||
|
{children}
|
||||||
|
</WalletModalProvider>
|
||||||
|
</WalletProvider>
|
||||||
|
</ConnectionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const userApi = {
|
|||||||
api.get(`/users/me/watch-history?page=${page}&limit=${limit}`),
|
api.get(`/users/me/watch-history?page=${page}&limit=${limit}`),
|
||||||
getRequests: (page = 1, limit = 20, status?: string) =>
|
getRequests: (page = 1, limit = 20, status?: string) =>
|
||||||
api.get(`/users/me/requests?page=${page}&limit=${limit}${status ? `&status=${status}` : ''}`),
|
api.get(`/users/me/requests?page=${page}&limit=${limit}${status ? `&status=${status}` : ''}`),
|
||||||
|
getLeaderboard: () => api.get('/users/leaderboard'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wallet API
|
// Wallet API
|
||||||
@@ -62,6 +63,7 @@ export const transactionApi = {
|
|||||||
getTransactions: (page = 1, limit = 20, type?: string) =>
|
getTransactions: (page = 1, limit = 20, type?: string) =>
|
||||||
api.get(`/transactions?page=${page}&limit=${limit}${type ? `&type=${type}` : ''}`),
|
api.get(`/transactions?page=${page}&limit=${limit}${type ? `&type=${type}` : ''}`),
|
||||||
getStats: () => api.get('/transactions/stats'),
|
getStats: () => api.get('/transactions/stats'),
|
||||||
|
getRecentActivity: () => api.get('/transactions/recent'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Admin API
|
// Admin API
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -19,9 +23,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"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
@@ -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
|
||||||
Generated
+662
-3969
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user