Initial Commit
--> Linted: CSS No errors were found in the linting process
Details
--> Linted: DOCKERFILE_HADOLINT No errors were found in the linting process
Details
--> Linted: EDITORCONFIG No errors were found in the linting process
Details
--> Linted: GITHUB_ACTIONS No errors were found in the linting process
Details
--> Linted: GITLEAKS No errors were found in the linting process
Details
--> Linted: JSON No errors were found in the linting process
Details
--> Linted: YAML No errors were found in the linting process
Details
lint
Details
build
Details
--> Linted: CSS No errors were found in the linting process
Details
--> Linted: DOCKERFILE_HADOLINT No errors were found in the linting process
Details
--> Linted: EDITORCONFIG No errors were found in the linting process
Details
--> Linted: GITHUB_ACTIONS No errors were found in the linting process
Details
--> Linted: GITLEAKS No errors were found in the linting process
Details
--> Linted: JSON No errors were found in the linting process
Details
--> Linted: YAML No errors were found in the linting process
Details
lint
Details
build
Details
This commit is contained in:
commit
5b470e5077
|
@ -0,0 +1,3 @@
|
|||
**/.git
|
||||
**/.node_modules
|
||||
.env
|
|
@ -0,0 +1,20 @@
|
|||
# editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
insert_final_newline = unset
|
||||
|
||||
[{Makefile,**.mk}]
|
||||
# Use tabs for indentation (Makefiles require tabs)
|
||||
indent_style = tab
|
|
@ -0,0 +1,7 @@
|
|||
PORT=5000
|
||||
COOKIE_SECRET=secret
|
||||
SESSION_SECRET=secret
|
||||
CSRF_SECRET_MUST_BE_32_CHARS=secretsecretsecretsecretsecret11
|
||||
BASE_URL=http://localhost:5000
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"env": {
|
||||
"es2021": true,
|
||||
"node": true,
|
||||
"browser": true
|
||||
},
|
||||
"extends": [
|
||||
"chase"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {},
|
||||
"globals": {}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
name: Bug Report
|
||||
about: Report incorrect or unexpected behavior
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
<!--
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files into it.
|
||||
-->
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Do '...'
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
blank_issues_enabled: true
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for the project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the feature request you'd like**
|
||||
A clear and concise description of what you want to have added.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
|
@ -0,0 +1,9 @@
|
|||
**Please describe the changes this PR makes and why it should be merged:**
|
||||
|
||||
<!--
|
||||
Please move lines that apply to you out of the comment:
|
||||
- Code changes have been fully tested, or there are no code changes
|
||||
- This PR includes breaking changes (methods removed or renamed, parameters moved or removed)
|
||||
- This PR **only** includes non-code changes, like changes to documentation, README, etc.
|
||||
- I have linted this PR and it passes all tests
|
||||
-->
|
|
@ -0,0 +1,15 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
The only supported version of this project is the latest commit. Nothing else is supported by us.
|
||||
|
||||
## Testing
|
||||
|
||||
If you believe you have found a vulnerability, be sure you test it in the latest commit.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you believe you have discovered a vulnerability or exploit and ensured it happens on the latest commit, please reach out to the project owner's email.
|
||||
|
||||
**Do not make a GitHub Issue for a security vulnerability.**
|
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
ignored: [DL3018]
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"title-require": false,
|
||||
"id-class-value": false,
|
||||
"head-script-disabled": false
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"rules": {
|
||||
"selector-class-pattern": null,
|
||||
"no-missing-end-of-source-newline": null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
rules:
|
||||
line-length:
|
||||
max: 260
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
name: "📦 Publish Docker Container"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
|
||||
env:
|
||||
PAT: ${{ secrets.PAT }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build the Docker Image
|
||||
run: docker build . --file Dockerfile --tag chase/topranks:latest --tag git.chse.dev/chase/topranks:latest
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.chse.dev
|
||||
username: chase
|
||||
password: ${{ secrets.PAT }}
|
||||
- name: Push the Docker Image to Container Registry
|
||||
run: docker push git.chse.dev/chase/topranks:latest
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
name: "🎨 Lint"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor != 'dependabot[bot]' && !contains(github.event.head_commit.message, '[skip lint]')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "📦️ Install Dependencies"
|
||||
run: |
|
||||
npm install
|
||||
|
||||
- name: "🎨 ESLint"
|
||||
run: npx eslint . --ext .js,.jsx,.ts,.tsx
|
||||
|
||||
- name: "🎨 Super-Linter"
|
||||
uses: github/super-linter/slim@v4
|
||||
env:
|
||||
VALIDATE_ALL_CODEBASE: true
|
||||
DEFAULT_BRANCH: main
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VALIDATE_JAVASCRIPT_ES: false
|
||||
VALIDATE_JAVASCRIPT_STANDARD: false
|
||||
VALIDATE_TYPESCRIPT_ES: false
|
||||
VALIDATE_TYPESCRIPT_STANDARD: false
|
||||
VALIDATE_JSCPD: false
|
||||
VALIDATE_MARKDOWN: false
|
||||
VALIDATE_NATURAL_LANGUAGE: false
|
|
@ -0,0 +1,12 @@
|
|||
# Packages
|
||||
node_modules/
|
||||
|
||||
# Private Data
|
||||
config.json
|
||||
.env
|
||||
*.db
|
||||
*.sqlite*
|
||||
|
||||
# Misc
|
||||
*.bak
|
||||
.DS_Store
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"github.vscode-pull-request-github",
|
||||
"eamodio.gitlens",
|
||||
"aaron-bond.better-comments",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"javascript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true,
|
||||
"javascript.format.placeOpenBraceOnNewLineForControlBlocks": true,
|
||||
"javascript.format.placeOpenBraceOnNewLineForFunctions": true,
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"editor.detectIndentation": false,
|
||||
"editor.tabSize": 4,
|
||||
"files.eol": "\n",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"cSpell.words": [
|
||||
"deepcode",
|
||||
"resendverifyemail",
|
||||
"updateemail"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
CMD [ "node", "." ]
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023 chase
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,9 @@
|
|||
.DEFAULT_GOAL:=help
|
||||
|
||||
.PHONY: help
|
||||
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
update: ## Bumps installed deps.
|
||||
@npx npm-check-updates -u && npm install
|
|
@ -0,0 +1,32 @@
|
|||
<div align="center">
|
||||
<h1><a href="https://top.chse.dev" target="_blank">TopRanks</a><br>
|
||||
<a href="https://chse.dev/donate"><img alt="Donate" src="https://img.shields.io/badge/Donate_To_This_Project-brightgreen"></a>
|
||||
</h1></div>
|
||||
|
||||
See your top ranking artists/tracks in a unique way.
|
||||
|
||||
![img](https://i.chse.dev/p/topranks.png)
|
||||
|
||||
## Running
|
||||
```bash
|
||||
docker run -d \
|
||||
--name TopRanks \
|
||||
-p 3000:3000 \
|
||||
-e BASE_URL=https://top.chse.dev \
|
||||
-e COOKIE_SECRET=asdf \
|
||||
-e SESSION_SECRET=asdf2 \
|
||||
-e CSRF_SECRET_MUST_BE_32_CHARS=asdf3 \
|
||||
-e SPOTIFY_CLIENT_ID=asdf4 \
|
||||
-e SPOTIFY_CLIENT_SECRET=asdf5 \
|
||||
git.chse.dev/chase/topranks:latest
|
||||
```
|
||||
|
||||
## Development
|
||||
```bash
|
||||
git clone https://git.chse.dev/chase/TopRanks.git
|
||||
cd TopRanks
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# Edit .env to your liking
|
||||
node .
|
||||
```
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "topranks",
|
||||
"version": "1.0.0",
|
||||
"description": "See your top ranking artists/tracks in a unique way.",
|
||||
"main": "./src/app.js",
|
||||
"engines": {
|
||||
"node": ">=16.14.0"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node ./src/app.js",
|
||||
"lint": "eslint --fix --ext .js,.jsx,.ts,.tsx ./src"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/chase/TopRanks.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Chase <c@chse.dev> (https://chse.dev)",
|
||||
"contributors": [
|
||||
""
|
||||
],
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/chase/TopRanks/issues"
|
||||
},
|
||||
"homepage": "https://github.com/chase/TopRanks#readme",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "6.4.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"bcrypt": "5.1.0",
|
||||
"better-sqlite3": "8.4.0",
|
||||
"better-sqlite3-session-store": "0.1.0",
|
||||
"body-parser": "1.20.2",
|
||||
"bootstrap": "5.3.0",
|
||||
"cookie-parser": "1.4.6",
|
||||
"dotenv": "16.3.1",
|
||||
"ejs": "3.1.9",
|
||||
"express": "4.18.2",
|
||||
"express-flash": "0.0.2",
|
||||
"express-rate-limit": "6.7.0",
|
||||
"express-session": "1.17.3",
|
||||
"helmet": "7.0.0",
|
||||
"jquery": "3.7.0",
|
||||
"morgan": "^1.10.0",
|
||||
"node-fetch": "3.3.1",
|
||||
"passport": "0.6.0",
|
||||
"passport-spotify": "2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "8.44.0",
|
||||
"eslint-config-chase": "1.0.8",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-jsdoc": "46.4.3",
|
||||
"eslint-plugin-unicorn": "47.0.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import express from "express";
|
||||
import { config } from "dotenv";
|
||||
import helmet from "helmet";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import session from "express-session";
|
||||
import cookieParser from "cookie-parser";
|
||||
import bodyParser from "body-parser";
|
||||
import path from "node:path";
|
||||
import morgan from "morgan";
|
||||
import flash from "express-flash";
|
||||
import passport from "passport";
|
||||
import { Strategy as SpotifyStrategy } from "passport-spotify";
|
||||
|
||||
import router from "./router.js";
|
||||
|
||||
config(); // Load environment variables from .env file
|
||||
|
||||
// file deepcode ignore UseCsurfForExpress: We are using tiny-csrf.
|
||||
const app = express();
|
||||
app.set(`trust proxy`, 1); // trust first proxy
|
||||
const port = process.env.PORT || 3000;
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
const sessionSecret = process.env.SESSION_SECRET;
|
||||
|
||||
const baseUrl = process.env.BASE_URL;
|
||||
let secureFlag = false;
|
||||
if (baseUrl.startsWith(`https://`))
|
||||
secureFlag = true;
|
||||
|
||||
// Hide ?code=[...] from logs
|
||||
morgan.token(`url`, (request) =>
|
||||
{
|
||||
if (request.query && request.query.code)
|
||||
return request.originalUrl.replace(`code=${ request.query.code }`, `code=[...]`);
|
||||
return request.originalUrl || request.url;
|
||||
});
|
||||
app.use(morgan(secureFlag ? `combined` : `dev`));
|
||||
|
||||
app.use(bodyParser.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser(cookieSecret));
|
||||
app.use(session({
|
||||
secret: sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
// file deepcode ignore WebCookieSecureDisabledExplicitly: We disable secure if baseUrl does not start with https.
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: secureFlag,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7, // 7d
|
||||
},
|
||||
}));
|
||||
app.use(flash());
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
app.use(express.static(`./src/public`)); // Serve static files from public on root (projectRoot/src/public/js -> example.com/js)
|
||||
// hotload jquery, bootstrap
|
||||
const projectRoot = path.resolve(`.`);
|
||||
app.use(`/css`, express.static(path.join(projectRoot, `node_modules/bootstrap/dist/css`)));
|
||||
app.use(`/js`, express.static(path.join(projectRoot, `node_modules/bootstrap/dist/js`)));
|
||||
app.use(`/js`, express.static(path.join(projectRoot, `node_modules/jquery/dist`)));
|
||||
app.use(`/css`, express.static(path.join(projectRoot, `node_modules/@fortawesome/fontawesome-free/css`)));
|
||||
app.use(`/webfonts`, express.static(path.join(projectRoot, `node_modules/@fortawesome/fontawesome-free/webfonts`)));
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||
"img-src": [`'self'`, `data:`, `chart.googleapis.com`],
|
||||
"script-src": [`'self'`, `https://*.hcaptcha.com`],
|
||||
"default-src": [`'self'`, `https://*.hcaptcha.com`],
|
||||
},
|
||||
},
|
||||
})); // Apply helmet to all requests
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
});
|
||||
app.use(limiter); // Apply rate limiter to all requests
|
||||
|
||||
/**
|
||||
* @name initializePassport
|
||||
* @description Initialize passport
|
||||
* @param {*} passport Passport.js Object
|
||||
*/
|
||||
function initializePassport(passport)
|
||||
{
|
||||
if (process.env.SPOTIFY_CLIENT_ID && process.env.SPOTIFY_CLIENT_SECRET)
|
||||
{
|
||||
/* eslint-disable camelcase */
|
||||
passport.use(new SpotifyStrategy(
|
||||
{
|
||||
clientID: process.env.SPOTIFY_CLIENT_ID,
|
||||
clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
|
||||
callbackURL: `${ baseUrl }/login/spotify/callback`,
|
||||
scope: [`user-top-read`],
|
||||
},
|
||||
(accessToken, refreshToken, expires_in, profile, done) => done(undefined, accessToken)
|
||||
));
|
||||
}
|
||||
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
passport.serializeUser((user, done) =>
|
||||
{
|
||||
done(undefined, user);
|
||||
});
|
||||
|
||||
passport.deserializeUser((user, done) =>
|
||||
{
|
||||
done(undefined, user);
|
||||
});
|
||||
}
|
||||
|
||||
initializePassport(
|
||||
passport,
|
||||
(email) => email,
|
||||
);
|
||||
|
||||
app.set(`views`, `./src/views`);
|
||||
app.set(`view engine`, `ejs`);
|
||||
|
||||
app.use(router); // Apply router from src/router.js
|
||||
|
||||
app.listen(port, () =>
|
||||
{
|
||||
console.log(`Server listening on http://localhost:${ port }!`);
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* @name checkAuthenticated
|
||||
* @description Check if user is authenticated (if auth'd, continue, else redirect to login)
|
||||
* @param {*} request Express request object
|
||||
* @param {*} response Express response object
|
||||
* @param {Function} next Express next function
|
||||
* @returns {void}
|
||||
*/
|
||||
export async function checkAuthenticated(request, response, next)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (request.isAuthenticated())
|
||||
return next();
|
||||
response.redirect(`/`);
|
||||
}
|
||||
catch
|
||||
{
|
||||
request.logout((error) =>
|
||||
{
|
||||
if (error)
|
||||
return response.status(500).json({ message: `Error logging out` });
|
||||
return response.redirect(`/`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name checkNotAuthenticated
|
||||
* @description Check if user is not authenticated (if not auth'd, continue, else redirect to root) [Used to prevent logged in users from accessing login/register pages, etc.]
|
||||
* @param {*} request Express request object
|
||||
* @param {*} response Express response object
|
||||
* @param {Function} next Express next function
|
||||
* @returns {void}
|
||||
*/
|
||||
export function checkNotAuthenticated(request, response, next)
|
||||
{
|
||||
if (!request.isAuthenticated())
|
||||
return next();
|
||||
return response.redirect(`/profile`); // Already logged in
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
:root {
|
||||
/* Dark Mode */
|
||||
--color-bg: #000000;
|
||||
--color-fg: #ffffff;
|
||||
--color-in-focus-from-bg: #383838;
|
||||
--link-color: #626262;
|
||||
--link-hover-color: #3f3f3f;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
/* Light Mode */
|
||||
--color-bg: #ffffff;
|
||||
--color-fg: #000000;
|
||||
--color-in-focus-from-bg: #b2b2b2;
|
||||
--link-color: #858585;
|
||||
--link-hover-color: #bcbcbc;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
main,
|
||||
ul,
|
||||
ol,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: auto;
|
||||
padding: 0px;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-width: 100vw;
|
||||
flex-direction: column;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
main#main {
|
||||
flex: 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
select,
|
||||
table {
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
-moz-text-align-last: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
display: inline-block;
|
||||
text-decoration: underline;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus,
|
||||
a:active {
|
||||
color: var(--link-hover-color);
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
@import url('./common.css');
|
||||
|
||||
html,
|
||||
body {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding-top: 10px;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1%;
|
||||
}
|
||||
|
||||
#socialSignOnIcons {
|
||||
margin: 0 auto;
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
.signInButton {
|
||||
display: inline-block;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
padding: 10px 20px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.signInButton .fa {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.signInButton:hover,
|
||||
.signInButton:focus,
|
||||
.signInButton:active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.spotify-button {
|
||||
background-color: #1DB954;
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Fredoka&display=swap');
|
||||
|
||||
svg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 1%;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#buttons,
|
||||
p {
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#mainList {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
}
|
||||
|
||||
p {
|
||||
color: white;
|
||||
font-size: larger;
|
||||
font-weight: bolder;
|
||||
text-shadow: 2px 2px 4px #000000;
|
||||
}
|
||||
|
||||
p#ad {
|
||||
margin-top: unset;
|
||||
}
|
||||
|
||||
ol,
|
||||
li,
|
||||
p {
|
||||
list-style-position: inside;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
color: white;
|
||||
text-shadow: 2px 2px 4px #000000;
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 56 KiB |
|
@ -0,0 +1,67 @@
|
|||
/* eslint-disable no-undef */
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
const warningTitleCSS = `color:red; font-size:60px; font-weight: bold; -webkit-text-stroke: 1px black;`;
|
||||
const warningDescCSS = `font-size: 18px;`;
|
||||
|
||||
for (let index = 0; index < 3; index++)
|
||||
{
|
||||
console.log(`%cStop!`, warningTitleCSS);
|
||||
console.log(`%cThis is a browser feature intended for developers.`, warningDescCSS);
|
||||
console.log(`%cIf someone told you to copy and paste something here to enable a feature or "hack" someone's account, it is a scam and will give them access to your account.`, warningDescCSS);
|
||||
console.log(`%cUnless you understand exactly what you are doing, close this window and stay safe.`, warningDescCSS);
|
||||
console.log(`%cSee https://en.wikipedia.org/wiki/Self-XSS for more information.`, warningDescCSS);
|
||||
console.log(`%c `, warningTitleCSS);
|
||||
}
|
||||
|
||||
// User has JS enabled, so let's show them the page.
|
||||
document.querySelector(`#main`).style.display = `block`;
|
||||
|
||||
// onclick of logout-link ID, execute logout();
|
||||
if (document.querySelector(`#logout-link`))
|
||||
document.querySelector(`#logout-link`).addEventListener(`click`, logout);
|
||||
|
||||
/**
|
||||
* @name logout
|
||||
* @description Logs the user out
|
||||
*/
|
||||
async function logout()
|
||||
{
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(`POST`, `/logout`, true);
|
||||
xhr.setRequestHeader(`Content-Type`, `application/json`);
|
||||
xhr.send(JSON.stringify({
|
||||
logout: true,
|
||||
}));
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* @name darkMode
|
||||
* @description Sets the dark mode live while user is switching their system settings.
|
||||
* @param {string} dark if user is using dark
|
||||
*/
|
||||
function darkMode(dark)
|
||||
{
|
||||
if (dark === `dark`)
|
||||
{ // Dark mode
|
||||
const hCaptcha = document.querySelector(`.h-captcha`);
|
||||
if (hCaptcha)
|
||||
hCaptcha.setAttribute(`data-theme`, `dark`);
|
||||
document.querySelector(`html`).setAttribute(`data-bs-theme`, `dark`);
|
||||
}
|
||||
else
|
||||
{ // Light mode
|
||||
const hCaptcha = document.querySelector(`.h-captcha`);
|
||||
if (hCaptcha)
|
||||
hCaptcha.setAttribute(`data-theme`, `light`);
|
||||
document.querySelector(`html`).setAttribute(`data-bs-theme`, `light`);
|
||||
}
|
||||
}
|
||||
|
||||
window.matchMedia(`(prefers-color-scheme: dark)`).addEventListener(`change`, (event) =>
|
||||
{
|
||||
const colorScheme = event.matches ? `dark` : `light`;
|
||||
darkMode(colorScheme);
|
||||
});
|
||||
darkMode(window.matchMedia(`(prefers-color-scheme: dark)`).matches ? `dark` : `light`);
|
|
@ -0,0 +1,117 @@
|
|||
const timeRangeButtons = document.querySelector(`#timeRangeButtons`);
|
||||
const dataTypeButtons = document.querySelector(`#dataTypeButtons`);
|
||||
|
||||
const artistsDataAllTimeNames = document.querySelector(`#artistsDataAllTimeNames`);
|
||||
const artistsDataSixMonthsNames = document.querySelector(`#artistsDataSixMonthsNames`);
|
||||
const artistsDataFourWeeksNames = document.querySelector(`#artistsDataFourWeeksNames`);
|
||||
const trackDataAllTimeNames = document.querySelector(`#trackDataAllTimeNames`);
|
||||
const trackDataSixMonthsNames = document.querySelector(`#trackDataSixMonthsNames`);
|
||||
const trackDataFourWeeksNames = document.querySelector(`#trackDataFourWeeksNames`);
|
||||
|
||||
timeRangeButtons.addEventListener(`click`, (event) =>
|
||||
{
|
||||
const clickedButton = event.target;
|
||||
const activeTimeRangeButton = timeRangeButtons.querySelector(`.btn-primary`);
|
||||
activeTimeRangeButton.classList.remove(`btn-primary`);
|
||||
activeTimeRangeButton.classList.add(`btn-secondary`);
|
||||
clickedButton.classList.add(`btn-primary`);
|
||||
clickedButton.classList.remove(`btn-secondary`);
|
||||
|
||||
const selectedDataTypeButton = dataTypeButtons.querySelector(`.btn-primary`);
|
||||
const selectedDataType = selectedDataTypeButton.id;
|
||||
const selectedTimeRange = clickedButton.id;
|
||||
|
||||
handleHiding(selectedTimeRange, selectedDataType);
|
||||
});
|
||||
|
||||
dataTypeButtons.addEventListener(`click`, (event) =>
|
||||
{
|
||||
const clickedButton = event.target;
|
||||
const activeDataTypeButton = dataTypeButtons.querySelector(`.btn-primary`);
|
||||
activeDataTypeButton.classList.remove(`btn-primary`);
|
||||
activeDataTypeButton.classList.add(`btn-secondary`);
|
||||
clickedButton.classList.add(`btn-primary`);
|
||||
clickedButton.classList.remove(`btn-secondary`);
|
||||
|
||||
const selectedTimeRangeButton = timeRangeButtons.querySelector(`.btn-primary`);
|
||||
const selectedTimeRange = selectedTimeRangeButton.id;
|
||||
const selectedDataType = clickedButton.id;
|
||||
|
||||
handleHiding(selectedTimeRange, selectedDataType);
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} timeRange The time range
|
||||
* @param {string} dataType The data type
|
||||
*/
|
||||
function handleHiding(timeRange, dataType)
|
||||
{
|
||||
if (timeRange === `fourWeeks`
|
||||
&& dataType === `artists`)
|
||||
{
|
||||
artistsDataAllTimeNames.classList.add(`d-none`);
|
||||
artistsDataSixMonthsNames.classList.add(`d-none`);
|
||||
artistsDataFourWeeksNames.classList.remove(`d-none`);
|
||||
trackDataAllTimeNames.classList.add(`d-none`);
|
||||
trackDataSixMonthsNames.classList.add(`d-none`);
|
||||
trackDataFourWeeksNames.classList.add(`d-none`);
|
||||
}
|
||||
else if (timeRange === `sixMonths`
|
||||
&& dataType === `artists`)
|
||||
{
|
||||
artistsDataAllTimeNames.classList.add(`d-none`);
|
||||
artistsDataSixMonthsNames.classList.remove(`d-none`);
|
||||
artistsDataFourWeeksNames.classList.add(`d-none`);
|
||||
trackDataAllTimeNames.classList.add(`d-none`);
|
||||
trackDataSixMonthsNames.classList.add(`d-none`);
|
||||
trackDataFourWeeksNames.classList.add(`d-none`);
|
||||
}
|
||||
else if (timeRange === `allTime`
|
||||
&& dataType === `artists`)
|
||||
{
|
||||
artistsDataAllTimeNames.classList.remove(`d-none`);
|
||||
artistsDataSixMonthsNames.classList.add(`d-none`);
|
||||
artistsDataFourWeeksNames.classList.add(`d-none`);
|
||||
trackDataAllTimeNames.classList.add(`d-none`);
|
||||
trackDataSixMonthsNames.classList.add(`d-none`);
|
||||
trackDataFourWeeksNames.classList.add(`d-none`);
|
||||
}
|
||||
else if (timeRange === `fourWeeks`
|
||||
&& dataType === `tracks`)
|
||||
{
|
||||
artistsDataAllTimeNames.classList.add(`d-none`);
|
||||
artistsDataSixMonthsNames.classList.add(`d-none`);
|
||||
artistsDataFourWeeksNames.classList.add(`d-none`);
|
||||
trackDataAllTimeNames.classList.add(`d-none`);
|
||||
trackDataSixMonthsNames.classList.add(`d-none`);
|
||||
trackDataFourWeeksNames.classList.remove(`d-none`);
|
||||
}
|
||||
else if (timeRange === `sixMonths`
|
||||
&& dataType === `tracks`)
|
||||
{
|
||||
artistsDataAllTimeNames.classList.add(`d-none`);
|
||||
artistsDataSixMonthsNames.classList.add(`d-none`);
|
||||
artistsDataFourWeeksNames.classList.add(`d-none`);
|
||||
trackDataAllTimeNames.classList.add(`d-none`);
|
||||
trackDataSixMonthsNames.classList.remove(`d-none`);
|
||||
trackDataFourWeeksNames.classList.add(`d-none`);
|
||||
}
|
||||
else if (timeRange === `allTime`
|
||||
&& dataType === `tracks`)
|
||||
{
|
||||
artistsDataAllTimeNames.classList.add(`d-none`);
|
||||
artistsDataSixMonthsNames.classList.add(`d-none`);
|
||||
artistsDataFourWeeksNames.classList.add(`d-none`);
|
||||
trackDataAllTimeNames.classList.remove(`d-none`);
|
||||
trackDataSixMonthsNames.classList.add(`d-none`);
|
||||
trackDataFourWeeksNames.classList.add(`d-none`);
|
||||
}
|
||||
}
|
||||
|
||||
const screenshotModeButton = document.querySelector(`#screenshotModeButton`);
|
||||
screenshotModeButton.addEventListener(`click`, () =>
|
||||
{
|
||||
const buttons = document.querySelectorAll(`.btn`);
|
||||
for (const button of buttons)
|
||||
button.style = `visibility: hidden;`;
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import express from "express";
|
||||
import { config } from "dotenv";
|
||||
import passport from "passport";
|
||||
|
||||
import index from "./routes/index.js";
|
||||
import notFound from "./routes/404.js";
|
||||
import logout from "./routes/logout.js";
|
||||
import profile from "./routes/profile.js";
|
||||
|
||||
config(); // Load environment variables from .env file
|
||||
|
||||
const router = new express.Router();
|
||||
|
||||
router.use(`/`, index);
|
||||
router.use(`/logout`, logout);
|
||||
router.use(`/profile`, profile);
|
||||
router.get(`/error`, (request, response) => response.send(`Error getting data. We may be rate limited by Spotify (try again later).`));
|
||||
|
||||
// Social Logins
|
||||
if (process.env.SPOTIFY_CLIENT_ID && process.env.SPOTIFY_CLIENT_SECRET)
|
||||
{
|
||||
router.get(`/login/spotify`, passport.authenticate(`spotify`, {
|
||||
scope: [`user-top-read`],
|
||||
}));
|
||||
router.get(
|
||||
`/login/spotify/callback`,
|
||||
passport.authenticate(`spotify`, {
|
||||
failureRedirect: `/`,
|
||||
}),
|
||||
(request, response) =>
|
||||
{
|
||||
// take the callback code from the url and redirect to /profile?code=...
|
||||
response.redirect(`/profile`);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
router.use(`/*`, notFound);
|
||||
|
||||
export default router;
|
|
@ -0,0 +1,10 @@
|
|||
import express from "express";
|
||||
|
||||
const router = new express.Router();
|
||||
|
||||
router.get(`/`, (request, response) =>
|
||||
{
|
||||
response.redirect(`/`);
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -0,0 +1,13 @@
|
|||
import express from "express";
|
||||
|
||||
const router = new express.Router();
|
||||
|
||||
// file deepcode ignore NoRateLimitingForExpensiveWebOperation: Already rate limited in src/app.js
|
||||
router.get(`/`, (request, response) =>
|
||||
{
|
||||
response.render(`index.ejs`, {
|
||||
loggedIn: request.isAuthenticated(),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -0,0 +1,15 @@
|
|||
import express from "express";
|
||||
|
||||
const router = new express.Router();
|
||||
|
||||
router.post(`/`, (request, response) =>
|
||||
{
|
||||
request.logout((error) =>
|
||||
{
|
||||
if (error)
|
||||
return response.status(500).json({ message: `Error logging out` });
|
||||
return response.redirect(`/`);
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -0,0 +1,139 @@
|
|||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
/* eslint-disable newline-per-chained-call */
|
||||
import express from "express";
|
||||
import { config } from "dotenv";
|
||||
import fetch from "node-fetch";
|
||||
import
|
||||
{
|
||||
checkAuthenticated
|
||||
} from "../functions.js";
|
||||
|
||||
const router = new express.Router();
|
||||
config(); // Load environment variables from .env file
|
||||
|
||||
// file deepcode ignore NoRateLimitingForExpensiveWebOperation: Already rate limited in src/app.js
|
||||
router.get(`/`, checkAuthenticated, async (request, response) =>
|
||||
{
|
||||
if (request.user)
|
||||
{
|
||||
const code = request.user;
|
||||
let artistsDataAllTimeNames; let artistsDataSixMonthsNames; let artistsDataFourWeeksNames; let trackDataAllTimeNames;
|
||||
let trackDataSixMonthsNames; let trackDataFourWeeksNames;
|
||||
if (process.env.DEV === `true`)
|
||||
{
|
||||
// Spotify API please save my wrath.
|
||||
artistsDataAllTimeNames = [
|
||||
`Artist Name`,
|
||||
`Artist Name`,
|
||||
`Artist Name`,
|
||||
`Artist Name`,
|
||||
`Artist Name`,
|
||||
`Artist Name`,
|
||||
`Artist Name`,
|
||||
`Artist Name`,
|
||||
`Artist Name`,
|
||||
`Artist Name`,
|
||||
];
|
||||
trackDataFourWeeksNames = [
|
||||
`Song Name - Artist Name`,
|
||||
`Song Name - Artist Name`,
|
||||
`Song Name - Artist Name`,
|
||||
`Song Name - Artist Name`,
|
||||
`Song Name - Artist Name`,
|
||||
`Song Name - Artist Name`,
|
||||
`Song Name - Artist Name`,
|
||||
`Song Name - Artist Name`,
|
||||
`Song Name - Artist Name`,
|
||||
`Song Name - Artist Name`,
|
||||
];
|
||||
artistsDataSixMonthsNames = artistsDataAllTimeNames;
|
||||
artistsDataFourWeeksNames = artistsDataAllTimeNames;
|
||||
trackDataSixMonthsNames = trackDataFourWeeksNames;
|
||||
trackDataAllTimeNames = trackDataFourWeeksNames;
|
||||
}
|
||||
else
|
||||
{
|
||||
const artistsDataAllTime = await fetch(`https://api.spotify.com/v1/me/top/artists?time_range=long_term&limit=10`, {
|
||||
method: `GET`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ code }`,
|
||||
},
|
||||
}).then((response) => response.json());
|
||||
|
||||
if (artistsDataAllTime.error)
|
||||
return response.redirect(`/error`);
|
||||
|
||||
artistsDataAllTimeNames = artistsDataAllTime.items.map((item) => item.name);
|
||||
|
||||
const artistsDataSixMonths = await fetch(`https://api.spotify.com/v1/me/top/artists?time_range=medium_term&limit=10`, {
|
||||
method: `GET`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ code }`,
|
||||
},
|
||||
}).then((response) => response.json());
|
||||
|
||||
artistsDataSixMonthsNames = artistsDataSixMonths.items.map((item) => item.name);
|
||||
|
||||
const artistsDataFourWeeks = await fetch(`https://api.spotify.com/v1/me/top/artists?time_range=short_term&limit=10`, {
|
||||
method: `GET`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ code }`,
|
||||
},
|
||||
}).then((response) => response.json());
|
||||
|
||||
artistsDataFourWeeksNames = artistsDataFourWeeks.items.map((item) => item.name);
|
||||
|
||||
const trackDataAllTime = await fetch(`https://api.spotify.com/v1/me/top/tracks?time_range=long_term&limit=10`, {
|
||||
method: `GET`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ code }`,
|
||||
},
|
||||
}).then((response) => response.json());
|
||||
|
||||
trackDataAllTimeNames = trackDataAllTime.items.map((item) =>
|
||||
{
|
||||
const artists = item.artists.map((artist) => artist.name);
|
||||
return `${ item.name } - ${ artists.join(`, `) }`;
|
||||
});
|
||||
|
||||
const trackDataSixMonths = await fetch(`https://api.spotify.com/v1/me/top/tracks?time_range=medium_term&limit=10`, {
|
||||
method: `GET`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ code }`,
|
||||
},
|
||||
}).then((response) => response.json());
|
||||
|
||||
trackDataSixMonthsNames = trackDataSixMonths.items.map((item) =>
|
||||
{
|
||||
const artists = item.artists.map((artist) => artist.name);
|
||||
return `${ item.name } - ${ artists.join(`, `) }`;
|
||||
});
|
||||
|
||||
const trackDataFourWeeks = await fetch(`https://api.spotify.com/v1/me/top/tracks?time_range=short_term&limit=10`, {
|
||||
method: `GET`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ code }`,
|
||||
},
|
||||
}).then((response) => response.json());
|
||||
|
||||
trackDataFourWeeksNames = trackDataFourWeeks.items.map((item) =>
|
||||
{
|
||||
const artists = item.artists.map((artist) => artist.name);
|
||||
return `${ item.name } - ${ artists.join(`, `) }`;
|
||||
});
|
||||
}
|
||||
|
||||
response.render(`profile.ejs`, {
|
||||
artistsDataAllTimeNames,
|
||||
artistsDataSixMonthsNames,
|
||||
artistsDataFourWeeksNames,
|
||||
trackDataAllTimeNames,
|
||||
trackDataSixMonthsNames,
|
||||
trackDataFourWeeksNames,
|
||||
});
|
||||
}
|
||||
else
|
||||
return response.redirect(`/login/spotify`);
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -0,0 +1,57 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" prefix="og: https://ogp.me/ns#">
|
||||
|
||||
<head>
|
||||
<title>TopRanks</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="See your top ranking artists/tracks in a unique way." />
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta property="og:title" content="TopRanks" />
|
||||
<meta property="og:description" content="See your top ranking artists/tracks in a unique way." />
|
||||
<meta property="og:image" content="/img/logo.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<script src="../js/jquery.min.js"></script>
|
||||
<link rel="stylesheet" href="../css/bootstrap.min.css">
|
||||
<script src="../js/bootstrap.bundle.min.js"></script>
|
||||
<link rel="stylesheet" href="../css/fontawesome.min.css">
|
||||
<link rel="stylesheet" href="../css/brands.min.css">
|
||||
|
||||
<link rel="stylesheet" href="/css/home.css">
|
||||
<link rel="icon" type="image/png" href="/img/logo.png">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
JavaScript is required to use this app.
|
||||
</noscript>
|
||||
<main id="main" style="display: none;">
|
||||
<h1>TopRanks</h1>
|
||||
<a href="/login/spotify" class="spotify-button signInButton">
|
||||
<i class="fab fa-spotify"></i>
|
||||
Sign In With Spotify
|
||||
</a>
|
||||
<hr>
|
||||
<h2>FAQ</h2>
|
||||
<h3>My Spotify most-played doesn't look right! How are these stats determined??</h3>
|
||||
<p>The short answer is, I don't know! <a
|
||||
href="https://developer.spotify.com/documentation/web-api/reference/get-users-top-artists-and-tracks"
|
||||
target="_blank">I use data directly collected by Spotify.</a>
|
||||
Since this data is updated by Spotify, I'm not sure how these stats are calculated.</p>
|
||||
|
||||
<h3>Can you show the number of times a track is played?</h3>
|
||||
<p>Unfortunately, Spotify doesn't provide this data.</p>
|
||||
|
||||
<h3>It's not working!</h3>
|
||||
<p>Sometimes, We might run into errors when there is increased traffic. Please try again in a
|
||||
few minutes.</p>
|
||||
|
||||
<h3>Will you log my data?</h3>
|
||||
<p>I do not log any personal information. If you want to share your music taste
|
||||
with me, <a href="mailto:toptracks@chse.dev" target="_blank">shoot me an email</a>.</p>
|
||||
</main>
|
||||
<script src="/js/common.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -0,0 +1,163 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" prefix="og: https://ogp.me/ns#">
|
||||
|
||||
<head>
|
||||
<title>TopRanks</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="See your top ranking artists/tracks in a unique way." />
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta property="og:title" content="TopRanks" />
|
||||
<meta property="og:description" content="See your top ranking artists/tracks in a unique way." />
|
||||
<meta property="og:image" content="/img/logo.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<script src="../js/jquery.min.js"></script>
|
||||
<link rel="stylesheet" href="../css/bootstrap.min.css">
|
||||
<script src="../js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- <link rel="stylesheet" href="/css/common.css"> -->
|
||||
<link rel="stylesheet" href="/css/profile.css">
|
||||
<link rel="icon" type="image/png" href="/img/logo.png">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
JavaScript is required to use this app.
|
||||
</noscript>
|
||||
|
||||
<svg preserveAspectRatio="xMidYMid slice" viewBox="10 10 80 80">
|
||||
<defs>
|
||||
<style>
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.out-top {
|
||||
animation: rotate 20s linear infinite;
|
||||
transform-origin: 13px 25px;
|
||||
}
|
||||
|
||||
.in-top {
|
||||
animation: rotate 10s linear infinite;
|
||||
transform-origin: 13px 25px;
|
||||
}
|
||||
|
||||
.out-bottom {
|
||||
animation: rotate 25s linear infinite;
|
||||
transform-origin: 84px 93px;
|
||||
}
|
||||
|
||||
.in-bottom {
|
||||
animation: rotate 15s linear infinite;
|
||||
transform-origin: 84px 93px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path fill="#9b5de5" class="out-top"
|
||||
d="M37-5C25.1-14.7,5.7-19.1-9.2-10-28.5,1.8-32.7,31.1-19.8,49c15.5,21.5,52.6,22,67.2,2.3C59.4,35,53.7,8.5,37-5Z" />
|
||||
<path fill="#f15bb5" class="in-top"
|
||||
d="M20.6,4.1C11.6,1.5-1.9,2.5-8,11.2-16.3,23.1-8.2,45.6,7.4,50S42.1,38.9,41,24.5C40.2,14.1,29.4,6.6,20.6,4.1Z" />
|
||||
<path fill="#00bbf9" class="out-bottom"
|
||||
d="M105.9,48.6c-12.4-8.2-29.3-4.8-39.4.8-23.4,12.8-37.7,51.9-19.1,74.1s63.9,15.3,76-5.6c7.6-13.3,1.8-31.1-2.3-43.8C117.6,63.3,114.7,54.3,105.9,48.6Z" />
|
||||
<path fill="#00f5d4" class="in-bottom"
|
||||
d="M102,67.1c-9.6-6.1-22-3.1-29.5,2-15.4,10.7-19.6,37.5-7.6,47.8s35.9,3.9,44.5-12.5C115.5,92.6,113.9,74.6,102,67.1Z" />
|
||||
</svg>
|
||||
|
||||
<main id="main" style="display: none;">
|
||||
<div id="buttons">
|
||||
<a href="#" id="logout-link" class="btn btn-secondary">Logout</a>
|
||||
<!-- buttons to select time range -->
|
||||
<div id="timeRangeButtons">
|
||||
<button id="fourWeeks" class="timeRangeButton btn btn-primary">Past Month</button>
|
||||
<button id="sixMonths" class="timeRangeButton btn btn-secondary">Six Months</button>
|
||||
<button id="allTime" class="timeRangeButton btn btn-secondary">All Time</button>
|
||||
</div>
|
||||
<!-- buttons to select data type -->
|
||||
<div id="dataTypeButtons">
|
||||
<button id="artists" class="dataTypeButton btn btn-primary">Artists</button>
|
||||
<button id="tracks" class="dataTypeButton btn btn-secondary">Tracks</button>
|
||||
</div>
|
||||
<button id="screenshotModeButton" class="btn btn-secondary">Screenshot Mode</button>
|
||||
</div>
|
||||
|
||||
<div id="mainList">
|
||||
<div id="artistsDataAllTimeNames" class="cd d-none">
|
||||
<p>Your top artists of all time</p>
|
||||
<ol id="artistsDataAllTimeNamesList">
|
||||
<% artistsDataAllTimeNames.forEach(function(artist) { %>
|
||||
<li>
|
||||
<%= artist %>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ol>
|
||||
<p id="ad">Presented by top.chse.dev</p>
|
||||
</div>
|
||||
<div id="artistsDataSixMonthsNames" class="cd d-none">
|
||||
<p>Your top artists of the last six months</p>
|
||||
<ol id="artistsDataSixMonthsNamesList">
|
||||
<% artistsDataSixMonthsNames.forEach(function(artist) { %>
|
||||
<li>
|
||||
<%= artist %>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ol>
|
||||
<p id="ad">Presented by top.chse.dev</p>
|
||||
</div>
|
||||
<div id="artistsDataFourWeeksNames" class="cd">
|
||||
<p>Your top artists of the month</p>
|
||||
<ol id="artistsDataFourWeeksNamesList">
|
||||
<% artistsDataFourWeeksNames.forEach(function(artist) { %>
|
||||
<li>
|
||||
<%= artist %>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ol>
|
||||
<p id="ad">Presented by top.chse.dev</p>
|
||||
</div>
|
||||
<div id="trackDataAllTimeNames" class="cd d-none">
|
||||
<p>Your top tracks of all time</p>
|
||||
<ol id="trackDataAllTimeNamesList">
|
||||
<% trackDataAllTimeNames.forEach(function(track) { %>
|
||||
<li>
|
||||
<%= track %>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ol>
|
||||
<p id="ad">Presented by top.chse.dev</p>
|
||||
</div>
|
||||
<div id="trackDataSixMonthsNames" class="cd d-none">
|
||||
<p>Your top tracks of the last six months</p>
|
||||
<ol id="trackDataSixMonthsNamesList">
|
||||
<% trackDataSixMonthsNames.forEach(function(track) { %>
|
||||
<li>
|
||||
<%= track %>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ol>
|
||||
<p id="ad">Presented by top.chse.dev</p>
|
||||
</div>
|
||||
<div id="trackDataFourWeeksNames" class="cd d-none">
|
||||
<p>Your top tracks of the month</p>
|
||||
<ol id="trackDataFourWeeksNamesList">
|
||||
<% trackDataFourWeeksNames.forEach(function(track) { %>
|
||||
<li>
|
||||
<%= track %>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ol>
|
||||
<p id="ad">Presented by top.chse.dev</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/profile.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
Reference in New Issue