From 77a0b69d9a0dd755a0a59a4c1dc3f3d045327e89 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Wed, 22 Nov 2023 21:58:45 -0800 Subject: feat: re-implement individual statistic pages on next --- backend/app.py | 83 ++++++++++++- backend/nijitrack.py | 3 - backend/sql/sql_handler.py | 11 +- components.json | 16 +++ package.json | 4 + pnpm-lock.yaml | 129 ++++++++++++++++++++- src/app/_componenets/Footer/Footer.tsx | 27 ----- .../SubscriberTable/SubscriberTable.tsx | 67 ----------- .../SubscriberTable/SubscriberTableRow.tsx | 35 ------ src/app/_componenets/TitleBar/TitleBar.tsx | 18 --- src/app/layout.tsx | 2 +- src/app/page.tsx | 4 +- src/components/DataChart/DataChart.tsx | 108 +++++++++++++++++ src/components/Footer/Footer.tsx | 26 +++++ src/components/SubscriberTable/SubscriberTable.tsx | 66 +++++++++++ .../SubscriberTable/SubscriberTableRow.tsx | 35 ++++++ src/components/TitleBar/TitleBar.tsx | 26 +++++ src/components/channel-card.tsx | 53 +++++++++ src/components/ui/avatar.tsx | 50 ++++++++ src/components/ui/badge.tsx | 36 ++++++ src/components/ui/card.tsx | 79 +++++++++++++ src/lib/utils.ts | 6 + src/pages/stats/[slug].tsx | 72 ++++++++++-- tailwind.config.ts | 2 +- 24 files changed, 787 insertions(+), 171 deletions(-) create mode 100644 components.json delete mode 100644 src/app/_componenets/Footer/Footer.tsx delete mode 100644 src/app/_componenets/SubscriberTable/SubscriberTable.tsx delete mode 100644 src/app/_componenets/SubscriberTable/SubscriberTableRow.tsx delete mode 100644 src/app/_componenets/TitleBar/TitleBar.tsx create mode 100644 src/components/DataChart/DataChart.tsx create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/SubscriberTable/SubscriberTable.tsx create mode 100644 src/components/SubscriberTable/SubscriberTableRow.tsx create mode 100644 src/components/TitleBar/TitleBar.tsx create mode 100644 src/components/channel-card.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/lib/utils.ts 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/") +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//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/") +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/Footer/Footer.tsx b/src/app/_componenets/Footer/Footer.tsx deleted file mode 100644 index f23c677..0000000 --- a/src/app/_componenets/Footer/Footer.tsx +++ /dev/null @@ -1,27 +0,0 @@ - -import React from 'react'; - -const Footer = () => { - return ( -
-
-

- Information -

-

- Information is collected once per hour. Data collection will stop once a liver has graduated. -
- This page is in now way affiliated with ANYCOLOR or with any of the channels listed here. -
- Date Started: 2023-03-26 -

-

- Source Code
- We are currently under construction! -

-
-
- ); -}; - -export default Footer; diff --git a/src/app/_componenets/SubscriberTable/SubscriberTable.tsx b/src/app/_componenets/SubscriberTable/SubscriberTable.tsx deleted file mode 100644 index a538bdd..0000000 --- a/src/app/_componenets/SubscriberTable/SubscriberTable.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from "react"; -import Image from "next/image"; -import ChannelRow from "./SubscriberTableRow"; - -interface ChannelDataProp { - channel_name: string; - profile_pic: string; - subscribers: number; - sub_org: string; - video_count: number; - day_diff: number; -} - -interface SubscriberDataTableProp { - channel_data: ChannelDataProp[]; - timestamp: string; -} - -const DataTable = ({ channel_data, timestamp }: SubscriberDataTableProp) => { - if (!channel_data) { - return null; - } - -return ( - <> -
-

Subscriber Count

-

Last Updated: {timestamp}

-
-
- - - - - - - - - - - - - {channel_data.map((channel, index) => ( - - ))} - -
- RANK - - CHANNEL - - GROUP - - VIDEO COUNT - - SUBSCRIBERS - - DIFF (24H) -
-
- -); -}; - -export default DataTable; -export type { SubscriberDataTableProp }; -export type { ChannelDataProp }; diff --git a/src/app/_componenets/SubscriberTable/SubscriberTableRow.tsx b/src/app/_componenets/SubscriberTable/SubscriberTableRow.tsx deleted file mode 100644 index 619a5b8..0000000 --- a/src/app/_componenets/SubscriberTable/SubscriberTableRow.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client" -import React from 'react'; -import Image from 'next/image'; -import { ChannelDataProp } from './SubscriberTable'; - -interface ChannelRowProps { - channel: ChannelDataProp; - index: number; -} - -const ChannelRow: React.FC = ({ channel, index }) => ( - - {index + 1} - - {channel.channel_name} - - {channel.channel_name} - - - {channel.sub_org} - {channel.video_count} - {Number(channel.subscribers).toLocaleString()} - - {channel.day_diff > 0 ? `+${Number(channel.day_diff).toLocaleString()}` : Number(channel.day_diff).toLocaleString()} - - -); - -export default ChannelRow; \ No newline at end of file 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 = ({ title }) => { - return ( -
-
- {title} -
-
- ); -}; - -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 = ({ channel_name, requestUrl, graphTitle }) => { + const [data, setData] = useState(); + 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
Loading...
; + } + + return ; +}; + +export default DataChart; \ No newline at end of file diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000..6585e52 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,26 @@ + +import React from 'react'; + +const Footer = () => { + return ( +
+
+

+ Information +

+

+ Information is collected once per hour. Data collection will stop once a liver has graduated. +
+ This page is in now way affiliated with ANYCOLOR or with any of the channels listed here. +
+ Date Started: 2023-03-26 +

+

+ Source Code
+

+
+
+ ); +}; + +export default Footer; diff --git a/src/components/SubscriberTable/SubscriberTable.tsx b/src/components/SubscriberTable/SubscriberTable.tsx new file mode 100644 index 0000000..8094e21 --- /dev/null +++ b/src/components/SubscriberTable/SubscriberTable.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import ChannelRow from "./SubscriberTableRow"; + +interface ChannelDataProp { + channel_name: string; + profile_pic: string; + subscribers: number; + sub_org: string; + video_count: number; + day_diff: number; +} + +interface SubscriberDataTableProp { + channel_data: ChannelDataProp[]; + timestamp: string; +} + +const DataTable = ({ channel_data, timestamp }: SubscriberDataTableProp) => { + if (!channel_data) { + return null; + } + +return ( + <> +
+

Subscriber Count

+

Last Updated: {timestamp}

+
+
+ + + + + + + + + + + + + {channel_data.map((channel, index) => ( + + ))} + +
+ RANK + + CHANNEL + + GROUP + + VIDEO COUNT + + SUBSCRIBERS + + DIFF (24H) +
+
+ +); +}; + +export default DataTable; +export type { SubscriberDataTableProp }; +export type { ChannelDataProp }; diff --git a/src/components/SubscriberTable/SubscriberTableRow.tsx b/src/components/SubscriberTable/SubscriberTableRow.tsx new file mode 100644 index 0000000..e97af1c --- /dev/null +++ b/src/components/SubscriberTable/SubscriberTableRow.tsx @@ -0,0 +1,35 @@ +"use client" +import React from 'react'; +import Image from 'next/image'; +import { ChannelDataProp } from './SubscriberTable'; + +interface ChannelRowProps { + channel: ChannelDataProp; + index: number; +} + +const ChannelRow: React.FC = ({ channel, index }) => ( + window.location.href = "/stats/"+channel.channel_name}> + {index + 1} + + {channel.channel_name} + + {channel.channel_name} + + + {channel.sub_org} + {channel.video_count} + {Number(channel.subscribers).toLocaleString()} + + {channel.day_diff > 0 ? `+${Number(channel.day_diff).toLocaleString()}` : Number(channel.day_diff).toLocaleString()} + + +); + +export default ChannelRow; \ No newline at end of file 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 = ({ title, redirectUrl, showHomeButton }) => { + return ( +
+
+ + {title} + + {showHomeButton && ( + + + + )} +
+
+ ); +}; + +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 ( + + +
+ + + PR + +
+ {name} + {suborg} +
+
+
+ +
+ Subscribers + {subscriberCount.toLocaleString()} +
+
+ Videos + {videoCount} +
+
+ Next Milestone + {nextMilestone} +
+ {nextMilestoneDays} days + {nextMilestoneDate} +
+ +
+
+
+ ) +} 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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +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 +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +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(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 ( -
-
-

Under Construction

-

We are currently working on this page. Please check back later.

-

Thank you for your patience

-

Slug: {router.query.slug}

+ <> + +
+
+ {channelData && ( + + )} +
-
+
+
+ +
+
+ +
+
+ ); -} \ 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 -- cgit v1.2.3