aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2023-11-22 21:58:45 -0800
committerPinapelz <yukais@pinapelz.com>2023-11-22 21:58:45 -0800
commit77a0b69d9a0dd755a0a59a4c1dc3f3d045327e89 (patch)
tree423c31591b868e0ccd4577c9b259f4895918f164
parent02e1e6ad3a4ca2a52e1045b5ed62858e55d8159b (diff)
feat: re-implement individual statistic pages on next
-rw-r--r--backend/app.py83
-rw-r--r--backend/nijitrack.py3
-rw-r--r--backend/sql/sql_handler.py11
-rw-r--r--components.json16
-rw-r--r--package.json4
-rw-r--r--pnpm-lock.yaml129
-rw-r--r--src/app/_componenets/TitleBar/TitleBar.tsx18
-rw-r--r--src/app/layout.tsx2
-rw-r--r--src/app/page.tsx4
-rw-r--r--src/components/DataChart/DataChart.tsx108
-rw-r--r--src/components/Footer/Footer.tsx (renamed from src/app/_componenets/Footer/Footer.tsx)1
-rw-r--r--src/components/SubscriberTable/SubscriberTable.tsx (renamed from src/app/_componenets/SubscriberTable/SubscriberTable.tsx)1
-rw-r--r--src/components/SubscriberTable/SubscriberTableRow.tsx (renamed from src/app/_componenets/SubscriberTable/SubscriberTableRow.tsx)2
-rw-r--r--src/components/TitleBar/TitleBar.tsx26
-rw-r--r--src/components/channel-card.tsx53
-rw-r--r--src/components/ui/avatar.tsx50
-rw-r--r--src/components/ui/badge.tsx36
-rw-r--r--src/components/ui/card.tsx79
-rw-r--r--src/lib/utils.ts6
-rw-r--r--src/pages/stats/[slug].tsx72
-rw-r--r--tailwind.config.ts2
21 files changed, 661 insertions, 45 deletions
diff --git a/backend/app.py b/backend/app.py
index 5ece4c7..7f38585 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -1,11 +1,14 @@
"""
Flask app for serving the static files
"""
-from flask import Flask, send_file, send_from_directory, jsonify, abort
+from flask import Flask, send_file, jsonify
from flask_cors import CORS
from sql.sql_handler import SQLHandler
import fileutil as fs
import datetime
+import pandas
+from sklearn.linear_model import LinearRegression
+import numpy as np
app = Flask(__name__)
CONFIG = fs.load_config("config.ini")
@@ -22,10 +25,82 @@ def api_subscribers():
data = server.execute_query("SELECT * FROM subscriber_data INNER JOIN 24h_historical ON subscriber_data.channel_id = 24h_historical.channel_id ORDER BY subscriber_count DESC")
channel_data_list = [{"channel_name":row[3], "profile_pic": row[2], "subscribers": row[4], "sub_org": row[5], "video_count": row[6], "day_diff": int(row[4] - int(row[10]))} for row in data]
subscriber_data = {"timestamp": datetime.datetime.now(),"channel_data":channel_data_list}
-
return jsonify(subscriber_data)
-
+@app.route("/api/subscribers/<channel_name>")
+def api_subscribers_channel(channel_name):
+ server = SQLHandler(CONFIG["SQL"]["host"], CONFIG["SQL"]["user"], CONFIG["SQL"]["password"], CONFIG["SQL"]["database"])
+ data = server.execute_query("SELECT * FROM subscriber_data_historical WHERE name = %s", (channel_name,))
+ labels = []
+ data_points = []
+ seen_dates = set()
+ for row in data:
+ date_string = row[5].strftime("%Y-%m-%d")
+ if date_string in seen_dates:
+ continue
+ labels.append(date_string)
+ data_points.append(row[4])
+ seen_dates.add(date_string)
+ return jsonify({"labels": labels, "datasets": data_points})
+
+
+@app.route("/api/subscribers/<channel_name>/7d")
+def api_subscribers_channel_7d(channel_name):
+ server = SQLHandler(CONFIG["SQL"]["host"], CONFIG["SQL"]["user"], CONFIG["SQL"]["password"], CONFIG["SQL"]["database"])
+ data = server.execute_query("SELECT * FROM subscriber_data_historical WHERE name = %s", (channel_name,))
+ labels = []
+ data_points = []
+ seen_dates = set()
+ for row in data:
+ date_string = row[5].strftime("%Y-%m-%d")
+ if date_string in seen_dates:
+ continue
+ labels.append(date_string)
+ data_points.append(row[4])
+ seen_dates.add(date_string)
+ return jsonify({"labels": labels[-7:], "datasets": data_points[-7:]})
+
+@app.route("/api/channel/<channel_name>")
+def get_channel_information(channel_name):
+ def find_next_milestone(subscriber_count):
+ if subscriber_count < 100000:
+ return 100000
+ elif subscriber_count < 1000000:
+ return ((subscriber_count // 100000) + 1) * 100000
+ else:
+ return ((subscriber_count // 1000000) + 1) * 1000000
+ server = SQLHandler(CONFIG["SQL"]["host"], CONFIG["SQL"]["user"], CONFIG["SQL"]["password"], CONFIG["SQL"]["database"])
+ data = server.execute_query("SELECT * FROM subscriber_data WHERE name = %s", (channel_name,))
+ channel_data = {"channel_name":data[0][3], "profile_pic": data[0][2], "subscribers": data[0][4], "sub_org": data[0][5], "video_count": data[0][6]}
+
+ historical_data = server.execute_query("SELECT * FROM subscriber_data_historical WHERE name = %s", (channel_name,))
+ current_subscriber_count = data[0][4]
+ subscriber_points = []
+ date_strings = []
+ seen_dates = set()
+ for row in historical_data:
+ date_string = row[5].strftime("%Y-%m-%d")
+ if date_string in seen_dates:
+ continue
+ subscriber_points.append(row[4])
+ date_strings.append(date_string)
+ seen_dates.add(date_string)
+ data = {"subscribers": subscriber_points, "dates": date_strings}
+ df = pandas.DataFrame(data=data)
+ df['dates'] = pandas.to_datetime(df['dates'])
+ df.set_index('dates', inplace=True)
+ model = LinearRegression()
+ X = np.array(range(len(df))).reshape(-1, 1)
+ y = df['subscribers']
+ model.fit(X, y)
+ next_milestone = find_next_milestone(current_subscriber_count)
+ days_until_next_milestone = (next_milestone - model.intercept_) / model.coef_
+ next_milestone_date = (df.index[0] + pandas.Timedelta(days=int(days_until_next_milestone))).date()
+ time_until_next_milestone = (next_milestone_date - datetime.datetime.now().date()).days
+ channel_data["next_milestone_date"] = str(next_milestone_date)
+ channel_data["days_until_next_milestone"] = str(time_until_next_milestone)
+ channel_data["next_milestone"] = str(next_milestone)
+ return jsonify(channel_data)
@app.errorhandler(404)
def not_found(error):
@@ -33,4 +108,4 @@ def not_found(error):
if __name__ == "__main__":
- app.run(debug=True)
+ app.run(debug=True, port=5001)
diff --git a/backend/nijitrack.py b/backend/nijitrack.py
index e3b97c6..b894585 100644
--- a/backend/nijitrack.py
+++ b/backend/nijitrack.py
@@ -69,9 +69,6 @@ def holodex_generation(server: SQLHandler):
holodex.set_organization(organization)
subscriber_data = holodex.get_subscriber_data()
record_subscriber_data(subscriber_data)
- #for channel in subscriber_data:
- # print(channel["name"] + " " + channel["group"] + " " + channel["video_count"] )
- #input()
return holodex.get_generated_channel_data(), holodex.get_inactive_channels()
@log("Running YouTube Generation")
diff --git a/backend/sql/sql_handler.py b/backend/sql/sql_handler.py
index 9d1c10d..82f071d 100644
--- a/backend/sql/sql_handler.py
+++ b/backend/sql/sql_handler.py
@@ -140,8 +140,17 @@ class SQLHandler:
print("Error updating row")
print(err)
- def execute_query(self, query: str):
+ def execute_query(self, query: str, data: tuple = None):
cursor = self.connection.cursor(buffered=True)
+ if data:
+ try:
+ cursor.execute(query, data)
+ result = cursor.fetchall()
+ return result
+ except Error as err:
+ print("Error executing query")
+ print(err)
+ return None
try:
cursor.execute(query)
result = cursor.fetchall()
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..2fceff5
--- /dev/null
+++ b/components.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "src/app/globals.css",
+ "baseColor": "gray",
+ "cssVariables": false
+ },
+ "aliases": {
+ "utils": "@/lib/utils",
+ "components": "@/components"
+ }
+} \ No newline at end of file
diff --git a/package.json b/package.json
index 9bbc027..4213d6c 100644
--- a/package.json
+++ b/package.json
@@ -10,8 +10,12 @@
},
"dependencies": {
"@canvasjs/react-charts": "^1.0.0",
+ "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-icons": "^1.3.0",
+ "avatar": "link:componenets/ui/avatar",
"axios": "^1.6.2",
+ "badge": "link:componenets/ui/badge",
+ "card": "link:componenets/ui/card",
"chart.js": "^4.4.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc8ff34..a721549 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,12 +8,24 @@ dependencies:
'@canvasjs/react-charts':
specifier: ^1.0.0
version: 1.0.0(@canvasjs/charts@3.7.27)(react@18.2.0)
+ '@radix-ui/react-avatar':
+ specifier: ^1.0.4
+ version: 1.0.4(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-icons':
specifier: ^1.3.0
version: 1.3.0(react@18.2.0)
+ avatar:
+ specifier: link:componenets/ui/avatar
+ version: link:componenets/ui/avatar
axios:
specifier: ^1.6.2
version: 1.6.2
+ badge:
+ specifier: link:componenets/ui/badge
+ version: link:componenets/ui/badge
+ card:
+ specifier: link:componenets/ui/card
+ version: link:componenets/ui/card
chart.js:
specifier: ^4.4.0
version: 4.4.0
@@ -315,6 +327,58 @@ packages:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
+ /@radix-ui/react-avatar@1.0.4(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-kVK2K7ZD3wwj3qhle0ElXhOjbezIgyl2hVvgwfIdexL3rN6zJmy5AqqIf+D31lxVppdzV8CjAfZ6PklkmInZLw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.23.2
+ '@radix-ui/react-context': 1.0.1(@types/react@18.2.37)(react@18.2.0)
+ '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.37)(react@18.2.0)
+ '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.37)(react@18.2.0)
+ '@types/react': 18.2.37
+ '@types/react-dom': 18.2.15
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
+ /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.37)(react@18.2.0):
+ resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.23.2
+ '@types/react': 18.2.37
+ react: 18.2.0
+ dev: false
+
+ /@radix-ui/react-context@1.0.1(@types/react@18.2.37)(react@18.2.0):
+ resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.23.2
+ '@types/react': 18.2.37
+ react: 18.2.0
+ dev: false
+
/@radix-ui/react-icons@1.3.0(react@18.2.0):
resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==}
peerDependencies:
@@ -323,6 +387,70 @@ packages:
react: 18.2.0
dev: false
+ /@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.23.2
+ '@radix-ui/react-slot': 1.0.2(@types/react@18.2.37)(react@18.2.0)
+ '@types/react': 18.2.37
+ '@types/react-dom': 18.2.15
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
+ /@radix-ui/react-slot@1.0.2(@types/react@18.2.37)(react@18.2.0):
+ resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.23.2
+ '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.37)(react@18.2.0)
+ '@types/react': 18.2.37
+ react: 18.2.0
+ dev: false
+
+ /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.37)(react@18.2.0):
+ resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.23.2
+ '@types/react': 18.2.37
+ react: 18.2.0
+ dev: false
+
+ /@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.37)(react@18.2.0):
+ resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.23.2
+ '@types/react': 18.2.37
+ react: 18.2.0
+ dev: false
+
/@react-aria/ssr@3.9.0(react@18.2.0):
resolution: {integrity: sha512-Bz6BqP6ZorCme9tSWHZVmmY+s7AU8l6Vl2NUYmBzezD//fVHHfFo4lFBn5tBuAaJEm3AuCLaJQ6H2qhxNSb7zg==}
engines: {node: '>= 12'}
@@ -388,7 +516,6 @@ packages:
resolution: {integrity: sha512-HWMdW+7r7MR5+PZqJF6YFNSCtjz1T0dsvo/f1BV6HkV+6erD/nA7wd9NM00KVG83zf2nJ7uATPO9ttdIPvi3gg==}
dependencies:
'@types/react': 18.2.37
- dev: true
/@types/react-transition-group@4.4.9:
resolution: {integrity: sha512-ZVNmWumUIh5NhH8aMD9CR2hdW0fNuYInlocZHaZ+dgk/1K49j1w/HoAuK1ki+pgscQrOFRTlXeoURtuzEkV3dg==}
diff --git a/src/app/_componenets/TitleBar/TitleBar.tsx b/src/app/_componenets/TitleBar/TitleBar.tsx
deleted file mode 100644
index 27bebfc..0000000
--- a/src/app/_componenets/TitleBar/TitleBar.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import Image from 'next/image';
-
-interface TitleBarProps {
- title: string;
-}
-
-const TitleBar: React.FC<TitleBarProps> = ({ title }) => {
- return (
- <div className="title-bar p-5 shadow-md" style={{ backgroundColor: '#2D4B71' }}>
- <div style={{ width: 'fit-content', whiteSpace: 'nowrap', overflow: 'hidden' }}>
- <span className="text-white text-4xl font-bold">{title}</span>
- </div>
- </div>
- );
-};
-
-export default TitleBar; \ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ae0349f..3fdc727 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,6 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
-import Footer from './_componenets/Footer/Footer'
+import Footer from '../components/Footer/Footer'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 7a40c0a..6016d89 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,5 +1,5 @@
-import SubscriberTable, {SubscriberDataTableProp} from './_componenets/SubscriberTable/SubscriberTable';
-import TitleBar from './_componenets/TitleBar/TitleBar';
+import SubscriberTable, {SubscriberDataTableProp} from '../components/SubscriberTable/SubscriberTable';
+import TitleBar from '../components/TitleBar/TitleBar';
async function Home(){
const apiUrl = process.env.NEXT_PUBLIC_API_URL
diff --git a/src/components/DataChart/DataChart.tsx b/src/components/DataChart/DataChart.tsx
new file mode 100644
index 0000000..b51d592
--- /dev/null
+++ b/src/components/DataChart/DataChart.tsx
@@ -0,0 +1,108 @@
+
+
+import React, {useEffect, useState} from 'react';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import { Line } from 'react-chartjs-2';
+
+
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend
+);
+
+
+
+
+
+interface Dataset {
+ label: string;
+ data: number[];
+ borderColor: string;
+ backgroundColor: string;
+}
+
+interface DataChartResponseProps {
+ labels: string[];
+ datasets: Dataset[];
+}
+
+interface DataChartProps {
+ channel_name: string;
+ requestUrl?: string;
+ graphTitle?: string;
+}
+
+const DataChart: React.FC<DataChartProps> = ({ channel_name, requestUrl, graphTitle }) => {
+ const [data, setData] = useState<DataChartResponseProps | null>();
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const response = await fetch(requestUrl || `${apiUrl}/api/subscribers/${channel_name}`);
+ const json = await response.json();
+ setData({
+ labels: json.labels,
+ datasets: [
+ {
+ label: 'Subscriber Count',
+ data: json.datasets,
+ borderColor: 'rgb(255, 99, 132)',
+ backgroundColor: 'rgba(255, 99, 132, 0.5)',
+ },
+ ],
+ });
+ } catch (error) {
+ console.error('Error fetching data:', error);
+ }
+ };
+
+ fetchData();
+ }, [apiUrl, channel_name, requestUrl]);
+
+ const options = {
+ responsive: true,
+ plugins: {
+ legend: {
+ position: 'top' as const,
+ },
+ title: {
+ display: true,
+ text: graphTitle || 'Historical Subscriber Data',
+ font: {
+ size: 18
+ }
+ },
+ },
+ scales: {
+ x: {
+ ticks: {
+ autoSkip: true,
+ maxTicksLimit: 10
+ }
+ }
+ }
+ };
+
+ if (!data) {
+ return <div>Loading...</div>;
+ }
+
+ return <Line options={options} data={data} />;
+};
+
+export default DataChart; \ No newline at end of file
diff --git a/src/app/_componenets/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
index f23c677..6585e52 100644
--- a/src/app/_componenets/Footer/Footer.tsx
+++ b/src/components/Footer/Footer.tsx
@@ -17,7 +17,6 @@ const Footer = () => {
</p>
<p className="p-4">
<a className="hover:underline text-bold" href="https://github.com/pinapelz/Nijitrack">Source Code</a><br/>
- We are currently under construction!
</p>
</div>
</footer>
diff --git a/src/app/_componenets/SubscriberTable/SubscriberTable.tsx b/src/components/SubscriberTable/SubscriberTable.tsx
index a538bdd..8094e21 100644
--- a/src/app/_componenets/SubscriberTable/SubscriberTable.tsx
+++ b/src/components/SubscriberTable/SubscriberTable.tsx
@@ -1,5 +1,4 @@
import React from "react";
-import Image from "next/image";
import ChannelRow from "./SubscriberTableRow";
interface ChannelDataProp {
diff --git a/src/app/_componenets/SubscriberTable/SubscriberTableRow.tsx b/src/components/SubscriberTable/SubscriberTableRow.tsx
index 619a5b8..e97af1c 100644
--- a/src/app/_componenets/SubscriberTable/SubscriberTableRow.tsx
+++ b/src/components/SubscriberTable/SubscriberTableRow.tsx
@@ -9,7 +9,7 @@ interface ChannelRowProps {
}
const ChannelRow: React.FC<ChannelRowProps> = ({ channel, index }) => (
-<tr key={index} className="border-b hover:bg-gray-100 cursor-pointer">
+<tr key={index} className="border-b hover:bg-gray-100 cursor-pointer" onClick={() => window.location.href = "/stats/"+channel.channel_name}>
<td className="py-3 px-1 sm:px-3 hidden sm:table-cell">{index + 1}</td>
<td className="py-3 px-1 sm:px-3 flex items-center">
<Image
diff --git a/src/components/TitleBar/TitleBar.tsx b/src/components/TitleBar/TitleBar.tsx
new file mode 100644
index 0000000..85fbfbd
--- /dev/null
+++ b/src/components/TitleBar/TitleBar.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+interface TitleBarProps {
+ title: string;
+ redirectUrl?: string;
+ showHomeButton?: boolean;
+}
+
+const TitleBar: React.FC<TitleBarProps> = ({ title, redirectUrl, showHomeButton }) => {
+ return (
+ <div className="title-bar p-5 shadow-md" style={{ backgroundColor: '#2D4B71' }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+ <a href={redirectUrl}>
+ <span className="text-white text-4xl font-bold">{title}</span>
+ </a>
+ {showHomeButton && (
+ <a href="/">
+ <button className="bg-white text-blue-500 hover:bg-blue-500 hover:text-white font-bold py-2 px-4 rounded-full">Home</button>
+ </a>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default TitleBar; \ No newline at end of file
diff --git a/src/components/channel-card.tsx b/src/components/channel-card.tsx
new file mode 100644
index 0000000..f2eed59
--- /dev/null
+++ b/src/components/channel-card.tsx
@@ -0,0 +1,53 @@
+import { AvatarImage, AvatarFallback, Avatar } from "@/components/ui/avatar"
+import { CardTitle, CardHeader, CardContent, Card } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+
+interface ChannelCardProps {
+ name: string
+ avatarUrl: string
+ subscriberCount: number
+ videoCount: number
+ suborg: string
+ nextMilestone: string
+ nextMilestoneDays: string
+ nextMilestoneDate: string
+}
+
+export function ChannelCard(props: ChannelCardProps) {
+ const { name, avatarUrl, subscriberCount, videoCount, suborg, nextMilestone, nextMilestoneDays, nextMilestoneDate } = props
+ return (
+ <Card className="w-[500px] shadow-lg rounded-lg overflow-hidden mt-4 py-4">
+ <CardHeader>
+ <div className="flex items-center space-x-4 p-4">
+ <Avatar>
+ <AvatarImage src={avatarUrl}/>
+ <AvatarFallback>PR</AvatarFallback>
+ </Avatar>
+ <div>
+ <CardTitle>{name}</CardTitle>
+ <Badge variant="secondary">{suborg}</Badge>
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent className="px-4 py-2 space-y-4">
+ <div className="flex flex-col items-center">
+ <span className="text-l text-gray-600">Subscribers</span>
+ <span className="font-semibold">{subscriberCount.toLocaleString()}</span>
+ </div>
+ <div className="flex flex-col items-center">
+ <span className="text-l text-gray-600">Videos</span>
+ <span className="font-semibold">{videoCount}</span>
+ </div>
+ <div className="flex flex-col items-center">
+ <span className="text-l text-gray-600">Next Milestone</span>
+ <span className="font-semibold">{nextMilestone}</span>
+ <div className="flex justify-center items-center">
+ <span className="text-sm text-gray-600 px-2">{nextMilestoneDays} days</span>
+ <span className="text-sm text-gray-600 px-2">{nextMilestoneDate}</span>
+ </div>
+
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..1c69f50
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Root
+ ref={ref}
+ className={cn(
+ "relative flex h-12 w-12 overflow-hidden rounded-full",
+ className
+ )}
+ {...props}
+ />
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Image>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Image
+ ref={ref}
+ className={cn("aspect-square h-full w-full", className)}
+ {...props}
+ />
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Fallback
+ ref={ref}
+ className={cn(
+ "flex h-full w-full items-center justify-center rounded-full bg-muted",
+ className
+ )}
+ {...props}
+ />
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..f000e3e
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes<HTMLDivElement>,
+ VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..0f7034b
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn(
+ "rounded-lg border bg-card text-card-foreground shadow-sm",
+ className
+ )}
+ {...props}
+ />
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("flex flex-col space-y-1 px-4", className)}
+ {...props}
+ />
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+ <h3
+ ref={ref}
+ className={cn(
+ "text-2xl font-semibold leading-none tracking-tight",
+ className
+ )}
+ {...props}
+ />
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+ <p
+ ref={ref}
+ className={cn("text-sm text-muted-foreground", className)}
+ {...props}
+ />
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("flex items-center p-6 pt-0", className)}
+ {...props}
+ />
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..d084cca
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/pages/stats/[slug].tsx b/src/pages/stats/[slug].tsx
index 03f8adf..1971c1e 100644
--- a/src/pages/stats/[slug].tsx
+++ b/src/pages/stats/[slug].tsx
@@ -1,16 +1,66 @@
-"use client"
-import { useRouter } from 'next/router'
-
+"use client";
+import React, { useEffect, useState } from "react";
+import { useRouter } from "next/router";
+import "../../app/globals.css";
+import TitleBar from "../../components/TitleBar/TitleBar";
+import { ChannelCard } from "@/components/channel-card";
+import DataChart from "@/components/DataChart/DataChart";
+import axios from "axios";
+
+interface ChannelDataProp {
+ channel_name: string;
+ profile_pic: string;
+ subscribers: number;
+ sub_org: string;
+ video_count: number;
+ next_milestone: string;
+ days_until_next_milestone: string;
+ next_milestone_date: string;
+}
+
export default function Page() {
+ const [channelData, setChannelData] = useState<ChannelDataProp | null>(null);
const router = useRouter();
+ const { slug } = router.query;
+ useEffect(() => {
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL;
+ if (slug) {
+ const encodedSlug = encodeURIComponent(slug as string);
+ console.log(apiUrl + `/api/channel/${encodedSlug}`);
+ axios.get(apiUrl + `/api/channel/${encodedSlug}`).then((response) => {
+ console.log(response);
+ setChannelData(response.data);
+ });
+ }
+ }, [slug]);
+
return (
- <div className="flex items-center justify-center h-screen">
- <div className="bg-black p-8 rounded-lg shadow-lg">
- <h1 className="text-2xl font-bold mb-4">Under Construction</h1>
- <p className="text-gray-600">We are currently working on this page. Please check back later.</p>
- <p className="text-gray-600">Thank you for your patience</p>
- <p className="text-gray-600">Slug: {router.query.slug}</p>
+ <>
+ <TitleBar title={slug as string} redirectUrl="/" showHomeButton />
+ <div className="flex justify-center">
+ <div className="flex flex-col items-center">
+ {channelData && (
+ <ChannelCard
+ name={channelData.channel_name}
+ avatarUrl={channelData.profile_pic}
+ subscriberCount={channelData.subscribers}
+ videoCount={channelData.video_count}
+ suborg={channelData.sub_org}
+ nextMilestone={channelData.next_milestone}
+ nextMilestoneDays={channelData.days_until_next_milestone}
+ nextMilestoneDate={channelData.next_milestone_date}
+ />
+ )}
+ </div>
</div>
- </div>
+ <div className="px-48 mb-10 mt-10">
+ <div className="mb-12">
+ <DataChart channel_name={slug as string}/>
+ </div>
+ <div className="mb-4">
+ <DataChart channel_name={slug as string} requestUrl={`${process.env.NEXT_PUBLIC_API_URL}/api/subscribers/${slug as string}/7d`} graphTitle="7 Day Historical"/>
+ </div>
+ </div>
+ </>
);
-} \ No newline at end of file
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 724d948..a930cb7 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -19,6 +19,6 @@ const config: Config = {
},
},
},
- plugins: [require("tailwindcss-animate")],
+ plugins: [require("tailwindcss-animate"), require("tailwindcss-animate")],
}
export default config
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage