├── src
├── App.css
├── components
│ ├── common
│ │ ├── CollectionList.css
│ │ ├── BarGraph.css
│ │ ├── FavouriteButton.css
│ │ ├── mode-catch.png
│ │ ├── mode-mania.png
│ │ ├── mode-osu.png
│ │ ├── mode-taiko.png
│ │ ├── EditableTextbox.css
│ │ ├── SortButton.css
│ │ ├── CollectionCard.css
│ │ ├── DifficultyBadge.js
│ │ ├── Glow.css
│ │ ├── BarGraph.js
│ │ ├── FavouriteButton.js
│ │ ├── SortButton.js
│ │ ├── CollectionList.js
│ │ ├── ModeCounters.js
│ │ ├── EditableTextbox.js
│ │ ├── FIleUpload.js
│ │ └── CollectionCard.js
│ ├── client
│ │ ├── import.png
│ │ ├── darkmode.png
│ │ ├── downloads.png
│ │ ├── SubscriptionDetailsModal.js
│ │ └── DesktopClient.js
│ ├── users
│ │ ├── usercoverfallback.jpg
│ │ ├── UserCard.css
│ │ ├── UserCard.js
│ │ ├── Users.js
│ │ ├── UserUploads.js
│ │ └── UserFavourites.js
│ ├── collection
│ │ ├── slimcoverfallback.jpg
│ │ ├── Comments.css
│ │ ├── MapsetCard.css
│ │ ├── MapsetCard.js
│ │ └── Comments.js
│ ├── home
│ │ ├── Home.css
│ │ └── Home.js
│ ├── navbar
│ │ ├── uploadModal.css
│ │ ├── NavButton.css
│ │ ├── LoginButton.js
│ │ ├── UserBadge.css
│ │ ├── UserBadge.js
│ │ ├── UploadModal.js
│ │ └── NavigationBar.js
│ ├── notfound
│ │ └── NotFound.js
│ ├── twitchSuccess
│ │ └── TwitchSuccess.js
│ ├── login
│ │ ├── ShowOtp.js
│ │ └── EnterOtp.js
│ ├── payments
│ │ ├── Success.js
│ │ └── Checkout.js
│ ├── bootstrap-osu-collector.js
│ ├── recent
│ │ └── Recent.js
│ ├── popular
│ │ └── Popular.js
│ └── all
│ │ └── All.js
├── config
│ ├── local.json
│ ├── production.json
│ ├── production-jun-testing.json
│ └── config.js
├── utils
│ ├── hooks.js
│ ├── utf8-decoder.js
│ ├── helpers.js
│ ├── uleb128.js
│ ├── misc.js
│ ├── collectionsDb.js
│ └── api.js
├── App.test.js
├── index.js
└── App.js
├── public
├── favicon.ico
├── osu!Collector logo.png
├── manifest.json
└── index.html
├── .vscode
├── tasks.json
└── launch.json
├── .gitignore
├── README.md
├── .eslintrc.json
└── package.json
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | font-family: 'Libre Franklin', sans-serif;
3 | }
--------------------------------------------------------------------------------
/src/components/common/CollectionList.css:
--------------------------------------------------------------------------------
1 | td {
2 | cursor: pointer;
3 | }
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/public/favicon.ico
--------------------------------------------------------------------------------
/src/components/common/BarGraph.css:
--------------------------------------------------------------------------------
1 | svg > g > g.google-visualization-tooltip { pointer-events: none }
--------------------------------------------------------------------------------
/src/components/common/FavouriteButton.css:
--------------------------------------------------------------------------------
1 | .favourite-button.btn:focus {
2 | box-shadow: none;
3 | }
--------------------------------------------------------------------------------
/public/osu!Collector logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/public/osu!Collector logo.png
--------------------------------------------------------------------------------
/src/components/client/import.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/src/components/client/import.png
--------------------------------------------------------------------------------
/src/components/client/darkmode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/src/components/client/darkmode.png
--------------------------------------------------------------------------------
/src/components/client/downloads.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/src/components/client/downloads.png
--------------------------------------------------------------------------------
/src/components/common/mode-catch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/src/components/common/mode-catch.png
--------------------------------------------------------------------------------
/src/components/common/mode-mania.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/src/components/common/mode-mania.png
--------------------------------------------------------------------------------
/src/components/common/mode-osu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/src/components/common/mode-osu.png
--------------------------------------------------------------------------------
/src/components/common/mode-taiko.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/src/components/common/mode-taiko.png
--------------------------------------------------------------------------------
/src/config/local.json:
--------------------------------------------------------------------------------
1 | {
2 | "CLIENT_ID": 7512,
3 | "OAUTH_CALLBACK": "https://osucollector.com/authentication",
4 | "API_HOST": ""
5 | }
--------------------------------------------------------------------------------
/src/config/production.json:
--------------------------------------------------------------------------------
1 | {
2 | "CLIENT_ID": 7512,
3 | "OAUTH_CALLBACK": "https://osucollector.com/authentication",
4 | "API_HOST": ""
5 | }
--------------------------------------------------------------------------------
/src/components/users/usercoverfallback.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/src/components/users/usercoverfallback.jpg
--------------------------------------------------------------------------------
/src/config/production-jun-testing.json:
--------------------------------------------------------------------------------
1 | {
2 | "CLIENT_ID": 8837,
3 | "OAUTH_CALLBACK": "http://72.39.80.182/authentication",
4 | "API_HOST": ""
5 | }
--------------------------------------------------------------------------------
/src/components/collection/slimcoverfallback.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/osuCollector-frontend/master/src/components/collection/slimcoverfallback.jpg
--------------------------------------------------------------------------------
/src/components/common/EditableTextbox.css:
--------------------------------------------------------------------------------
1 | .editable-textbox {
2 | cursor: pointer;
3 | }
4 | .editable-textbox:hover {
5 | background-color: #eee;
6 | }
--------------------------------------------------------------------------------
/src/utils/hooks.js:
--------------------------------------------------------------------------------
1 | import { useLocation } from "react-router-dom";
2 |
3 | function useQuery() {
4 | return new URLSearchParams(useLocation().search);
5 | }
6 |
7 | export {
8 | useQuery
9 | }
--------------------------------------------------------------------------------
/src/utils/utf8-decoder.js:
--------------------------------------------------------------------------------
1 | function decodeUtf8(uintArray) {
2 | let encodedString = String.fromCharCode.apply(null, Array.from(uintArray))
3 | let decodedString = decodeURIComponent(escape(encodedString))
4 | return decodedString
5 | }
6 |
7 | export { decodeUtf8 }
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render( );
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/src/config/config.js:
--------------------------------------------------------------------------------
1 | import local from './local.json'
2 | import production from './production.json'
3 |
4 | const config = {
5 | get: function(prop) {
6 | return process.env.NODE_ENV === 'production' ? production[prop] : local[prop]
7 | }
8 | }
9 | export default config
10 |
--------------------------------------------------------------------------------
/src/utils/helpers.js:
--------------------------------------------------------------------------------
1 | function validateEmail(email) {
2 | const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
3 | return re.test(email);
4 | }
5 |
6 | export {
7 | validateEmail
8 | }
--------------------------------------------------------------------------------
/src/components/home/Home.css:
--------------------------------------------------------------------------------
1 | .news {
2 | border-left: 2px solid rgb(179, 179, 179);
3 | padding-left: 1em;
4 | margin-left: 2em;
5 | margin-bottom: 2em;
6 | max-width: 50%;
7 | }
8 |
9 | .date {
10 | font-size: 0.9em;
11 | }
12 |
13 | .stats {
14 | max-width: 45%;
15 | }
16 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "npm",
6 | "script": "start",
7 | "problemMatcher": [],
8 | "label": "npm: start",
9 | "detail": "react-scripts start",
10 | "group": {
11 | "kind": "build",
12 | "isDefault": true
13 | }
14 | }
15 | ]
16 | }
--------------------------------------------------------------------------------
/src/components/navbar/uploadModal.css:
--------------------------------------------------------------------------------
1 | .dragon-drop {
2 | cursor: pointer;
3 | border: dashed 2px lightgray;
4 | padding: 20px;
5 | text-align: center;
6 | }
7 |
8 | .upload-buttons {
9 | float: right;
10 | }
11 |
12 | .upload-buttons .btn {
13 | margin-left: 5px;
14 | margin-right: 5px;
15 | }
--------------------------------------------------------------------------------
/src/components/notfound/NotFound.js:
--------------------------------------------------------------------------------
1 | import { Container } from "react-bootstrap";
2 |
3 | function NotFound() {
4 | return (
5 |
6 |
7 | 404 Not Found
8 |
9 |
10 | )
11 | }
12 |
13 | export default NotFound;
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom';
2 | import App from './App';
3 | import 'bootstrap/dist/css/bootstrap.min.css';
4 | import { BrowserRouter } from 'react-router-dom';
5 |
6 | ReactDOM.render(
7 |
8 |
9 | ,
10 | document.getElementById('root')
11 | );
12 |
--------------------------------------------------------------------------------
/src/components/navbar/NavButton.css:
--------------------------------------------------------------------------------
1 | .nav-button {
2 | background-color: transparent;
3 | color: #777;
4 | width: 40px;
5 | height: 40px;
6 | border-radius: 50px;
7 | display: inline-flex;
8 | align-items: center;
9 | }
10 |
11 | .nav-button:hover {
12 | background-color: #444;
13 | color: #fff;
14 | }
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/src/components/collection/Comments.css:
--------------------------------------------------------------------------------
1 | .noselect {
2 | -webkit-touch-callout: none; /* iOS Safari */
3 | -webkit-user-select: none; /* Safari */
4 | -khtml-user-select: none; /* Konqueror HTML */
5 | -moz-user-select: none; /* Old versions of Firefox */
6 | -ms-user-select: none; /* Internet Explorer/Edge */
7 | user-select: none; /* Non-prefixed version, currently
8 | supported by Chrome, Edge, Opera and Firefox */
9 | }
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "pwa-chrome",
9 | "request": "launch",
10 | "name": "Launch Chrome against localhost",
11 | "url": "http://localhost:3000",
12 | "webRoot": "${workspaceFolder}"
13 | }
14 | ]
15 | }
--------------------------------------------------------------------------------
/src/components/common/SortButton.css:
--------------------------------------------------------------------------------
1 | .sort-button-idle {
2 | outline: none;
3 | border: none;
4 | background-color: #f1f1f1;
5 | border-radius: 8px;
6 | }
7 |
8 | .sort-button-idle:hover,
9 | .sort-button-idle:active {
10 | background-color: #ddd;
11 | }
12 |
13 | .sort-button-active {
14 | outline: none;
15 | border: none;
16 | background-color: #e6e6e6;
17 | border-radius: 8px;
18 | }
19 |
20 | .sort-button-active:hover,
21 | .sort-button-active:active {
22 | background-color: #eee;
23 | }
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # osuCollector
2 | ### [osu!Collector](https://osucollector.com/) is a free-to-use service that allows you to easily share and favorite osu! beatmap collections uploaded by other players.
3 |
4 | ### [Support us](https://osucollector.com/client) to gain access to more awesome features 🥳
5 |
6 | ### The front end is open source, so don't be shy to make a pull request!
7 |
8 | 
9 | 
10 | 
11 | 
12 |
--------------------------------------------------------------------------------
/src/utils/uleb128.js:
--------------------------------------------------------------------------------
1 | // returns [result, bytesRead]
2 | function decodeULEB128(buf, startOffset = 0) {
3 | let result = 0
4 | let shift = 0
5 | let offset = startOffset
6 | // eslint-disable-next-line no-constant-condition
7 | while (true) {
8 | let byte = new Int8Array(buf, offset)[0]
9 | offset += 1
10 | result = result | ((byte & 0x7f) << shift)
11 | if ((byte & 0x80) === 0)
12 | break
13 | shift += 7
14 | }
15 | return [result, offset]
16 | }
17 |
18 | export { decodeULEB128 }
--------------------------------------------------------------------------------
/src/components/common/CollectionCard.css:
--------------------------------------------------------------------------------
1 | a.nostyle:link {
2 | text-decoration: inherit;
3 | color: inherit;
4 | cursor: auto;
5 | }
6 |
7 | a.nostyle:visited {
8 | text-decoration: inherit;
9 | color: inherit;
10 | cursor: auto;
11 | }
12 |
13 | .collection-card-uploader-avatar {
14 | width: 32px;
15 | height: 32px;
16 | }
17 |
18 | .collection-card-clickable {
19 | cursor: pointer;
20 | }
21 |
22 | .collection-card-graph-bg {
23 | background-color: #eee;
24 | }
25 |
26 | .muted-filter {
27 | filter: opacity(20%);
28 | }
--------------------------------------------------------------------------------
/src/components/navbar/LoginButton.js:
--------------------------------------------------------------------------------
1 | import { Button } from '../bootstrap-osu-collector';
2 | import config from '../../config/config'
3 |
4 | function LoginButton() {
5 | const clientId = config.get('CLIENT_ID')
6 | const callback = encodeURIComponent(config.get('OAUTH_CALLBACK'))
7 | return (
8 |
9 | Login
10 |
11 | )
12 | }
13 |
14 | export default LoginButton;
--------------------------------------------------------------------------------
/src/components/collection/MapsetCard.css:
--------------------------------------------------------------------------------
1 | .img-overlay-text {
2 | color: white;
3 | text-shadow: 2px 2px 4px #000, 2px 2px 4px #000, 2px 2px 4px #000;
4 | }
5 |
6 | .media-play-button {
7 | background-color: transparent;
8 | background-repeat: no-repeat;
9 | border: none;
10 | cursor: pointer;
11 | overflow: hidden;
12 | outline: none;
13 | color: #fff;
14 | text-shadow: 2px 2px 4px #000, 2px 2px 4px #000, 2px 2px 4px #000;
15 | }
16 |
17 | .svg-shadow {
18 | -webkit-filter: drop-shadow( 1px 1px 2px rgba(0, 0, 0, .7));
19 | filter: drop-shadow( 1px 1px 2px rgba(0, 0, 0, .7));
20 | /* Similar syntax to box-shadow */
21 | }
--------------------------------------------------------------------------------
/src/components/users/UserCard.css:
--------------------------------------------------------------------------------
1 | .user-cover {
2 | object-fit: cover;
3 | height: 89px;
4 | max-width: 100%;
5 | }
6 |
7 | .user-rank-left {
8 | display: block;
9 | align-items:center;
10 | }
11 | .user-rank-right {
12 | display: block;
13 | align-items:center;
14 | }
15 | .user-ranks {
16 | font-size: 2em;
17 | }
18 |
19 | .user-avatar {
20 | position: absolute;
21 | top: 21px;
22 | height: 80px;
23 | width: 80px;
24 | border: 2px solid #fff;
25 | border-radius: 50%;
26 | box-shadow: 0px 2px 9px #00000030, 0px 2px 9px #00000030;
27 | }
28 |
29 | .user-links {
30 | text-align: center;
31 | padding: 10px;
32 | }
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "node": true,
5 | "es2021": true
6 | },
7 | "extends": [
8 | "eslint:recommended",
9 | "plugin:react/recommended"
10 | ],
11 | "parserOptions": {
12 | "ecmaFeatures": {
13 | "jsx": true
14 | },
15 | "ecmaVersion": 12,
16 | "sourceType": "module"
17 | },
18 | "plugins": [
19 | "react",
20 | "react-hooks"
21 | ],
22 | "rules": {
23 | "react/react-in-jsx-scope": 0,
24 | "react/jsx-uses-react": 0,
25 | "react/prop-types": 0
26 | },
27 | "settings": {
28 | "react": {
29 | "version": "detect"
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/common/DifficultyBadge.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import { Badge, Image } from '../bootstrap-osu-collector'
3 | import { clamp, starToColor } from '../../utils/misc'
4 |
5 | function DifficultyBadge({ className, stars }) {
6 |
7 | return (
8 |
16 |
20 | {stars.toFixed(2)} ★
21 |
22 |
23 | )
24 | }
25 |
26 | export default DifficultyBadge
27 |
--------------------------------------------------------------------------------
/src/components/twitchSuccess/TwitchSuccess.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Container, Image } from '../bootstrap-osu-collector'
3 |
4 | function TwitchSuccess({ user }) {
5 | const twitchAva = user?.private?.linkedTwitchAccount.profilePictureUrl
6 | const twitchName = user?.private?.linkedTwitchAccount.displayName
7 | return (
8 |
9 |
10 |
Success!
11 | Your Twitch account has been linked.
12 |
13 | {twitchName}
14 |
15 |
16 | )
17 | }
18 |
19 | TwitchSuccess.propTypes = {
20 | user: PropTypes.object
21 | }
22 |
23 | export default TwitchSuccess
24 |
--------------------------------------------------------------------------------
/src/components/common/Glow.css:
--------------------------------------------------------------------------------
1 | .glowing {
2 | display: inline-block;
3 | -webkit-animation: glow 1s ease-in-out infinite alternate;
4 | -moz-animation: glow 1s ease-in-out infinite alternate;
5 | animation: glow 1s ease-in-out infinite alternate;
6 | }
7 |
8 | @keyframes glow {
9 | from {
10 | box-shadow:
11 | 0 0 calc(10px / 2) #fff,
12 | 0 0 calc(20px / 2) #fff,
13 | 0 0 calc(30px / 2) #e60073,
14 | 0 0 calc(40px / 2) #e60073,
15 | 0 0 calc(50px / 2) #e60073,
16 | 0 0 calc(60px / 2) #e60073,
17 | 0 0 calc(70px / 2) #e60073;
18 | }
19 | to {
20 | box-shadow:
21 | 0 0 calc(20px / 2) #fff,
22 | 0 0 calc(30px / 2) #ff4da6,
23 | 0 0 calc(40px / 2) #ff4da6,
24 | 0 0 calc(50px / 2) #ff4da6,
25 | 0 0 calc(60px / 2) #ff4da6,
26 | 0 0 calc(70px / 2) #ff4da6,
27 | 0 0 calc(80px / 2) #ff4da6;
28 | }
29 | }
--------------------------------------------------------------------------------
/src/components/login/ShowOtp.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from '../../utils/hooks';
2 | import { useState, useEffect } from 'react';
3 | import { Card, Container } from '../bootstrap-osu-collector';
4 |
5 | function ShowOtp() {
6 | const query = useQuery();
7 | const [otp, setOtp] = useState('')
8 | useEffect(async () => {
9 | setOtp(query.get('otp') || '');
10 | // eslint-disable-next-line react-hooks/exhaustive-deps
11 | }, [])
12 |
13 | return (
14 |
15 |
16 |
17 |
Please navigate back to the osu!Collector desktop app and enter this one time password:
18 | {otp}
19 |
20 |
21 |
22 | )
23 | }
24 |
25 | export default ShowOtp
26 |
--------------------------------------------------------------------------------
/src/components/payments/Success.js:
--------------------------------------------------------------------------------
1 | import Confetti from "react-confetti"
2 | import { Container, Row } from '../bootstrap-osu-collector'
3 | import { useWindowSize } from "react-use"
4 |
5 | function Success() {
6 | const { width, height } = useWindowSize()
7 | return (
8 |
9 |
29 |
30 | )
31 | }
32 |
33 | export default Success
34 |
--------------------------------------------------------------------------------
/src/components/common/BarGraph.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 | import Chart from 'react-google-charts'
3 | import { ThemeContext } from 'styled-components';
4 | import './BarGraph.css'
5 |
6 | const BarGraph = ({ chartEvents, data, enableInteractivity=false, height }) => {
7 | const theme = useContext(ThemeContext)
8 | return (
9 | }
13 | data={data}
14 | options={{
15 | legend: { position: 'none' },
16 | enableInteractivity: enableInteractivity,
17 | backgroundColor: theme.darkMode ? '#121212' : '#eee',
18 | hAxis: {
19 | textStyle: { color: '#555' }
20 | },
21 | vAxis: {
22 | baselineColor: theme.darkMode ? '#121212' : '#eee',
23 | gridlineColor: theme.darkMode ? '#121212' : '#eee',
24 | textPosition: 'none',
25 | }
26 | }}
27 | chartEvents={chartEvents}
28 | />
29 | )
30 | }
31 |
32 | export default BarGraph
33 |
--------------------------------------------------------------------------------
/src/components/common/FavouriteButton.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import { Button } from '../bootstrap-osu-collector'
3 | import { Heart, HeartFill } from 'react-bootstrap-icons'
4 | import './FavouriteButton.css'
5 | import { useContext } from 'react'
6 | import { ThemeContext } from 'styled-components'
7 |
8 | function FavouriteButton({ className, favourites, favourited, onClick }) {
9 |
10 | const theme = useContext(ThemeContext)
11 |
12 | const notFavouritedStyle = {
13 | color: 'white',
14 | background: theme.darkMode ? '#555' : '#AAB8C2'
15 | }
16 | const favouritedStyle = {
17 | color: 'white',
18 | background: '#FF66AB'
19 | }
20 | const currentStyle = favourited ? favouritedStyle : notFavouritedStyle
21 |
22 | return (
23 |
29 | {favourited ? : }
30 | Favorite
31 | {favourites ? ` (${favourites})` : ''}
32 |
33 | )
34 | }
35 |
36 | export default FavouriteButton
37 |
--------------------------------------------------------------------------------
/src/components/navbar/UserBadge.css:
--------------------------------------------------------------------------------
1 | .noselect {
2 | -webkit-touch-callout: none; /* iOS Safari */
3 | -webkit-user-select: none; /* Safari */
4 | -khtml-user-select: none; /* Konqueror HTML */
5 | -moz-user-select: none; /* Old versions of Firefox */
6 | -ms-user-select: none; /* Internet Explorer/Edge */
7 | user-select: none; /* Non-prefixed version, currently
8 | supported by Chrome, Edge, Opera and Firefox */
9 | }
10 |
11 | .user-badge {
12 | cursor: pointer;
13 | display: inline-flex;
14 | align-items: center;
15 | background-color: gray;
16 | border-radius: 50px;
17 | text-decoration: none;
18 | text-shadow: 1px 1px 2px #000000;
19 | }
20 |
21 | .user-badge-supporter {
22 | background-color: #FF66AB;
23 | }
24 |
25 | .user-badge:hover {
26 | text-decoration: none;
27 | background-color: darkgray;
28 | }
29 |
30 | .user-badge-supporter:hover {
31 | background-color: #ff91c2;
32 | }
33 |
34 | .user-badge a {
35 | text-decoration: none;
36 | }
37 |
38 | .user-badge span {
39 | padding: 0px 15px 0px 6px;
40 | color: white;
41 | vertical-align: text-top;
42 | }
43 |
44 | .user-badge .avatar-img {
45 | height: 30px;
46 | width: 30px;
47 | margin: 4px;
48 | object-fit: cover;
49 | }
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
23 | osu!Collector
24 |
25 |
26 |
27 | You need to enable JavaScript to run this app.
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/components/common/SortButton.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 | import './SortButton.css'
3 |
4 | const StyledButton = styled.button`
5 | outline: none;
6 | border: none;
7 | border-radius: 8px;
8 | color: ${props => props.theme.darkMode ? '#eee' : '#000'};
9 | background-color: ${props =>
10 | props.theme.darkMode ?
11 | (props.active ? props.theme.primary50 : props.theme.primary25)
12 | :
13 | (props.active ? '#e6e6e6' : '#f1f1f1')
14 | };
15 |
16 | &:hover, &:active {
17 | background-color: ${props =>
18 | props.theme.darkMode ?
19 | (props.active ? props.theme.primary60 : props.theme.primary35)
20 | :
21 | (props.active ? '#eee' : '#ddd')
22 | };
23 | }
24 | `
25 |
26 | // sortDirection: null or 'asc' or 'desc'
27 | function SortButton({ children, className, sortDirection, onClick }) {
28 |
29 | if (sortDirection === null) {
30 | return (
31 |
32 |
33 | {children}
34 |
35 |
36 | )
37 | }
38 | return (
39 |
40 | {children}
41 | {sortDirection === 'asc' ? : }
42 |
43 | )
44 | }
45 |
46 | export default SortButton
47 |
--------------------------------------------------------------------------------
/src/components/navbar/UserBadge.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useState } from 'react';
3 | import { Dropdown } from '../bootstrap-osu-collector';
4 | import Image from 'react-bootstrap/Image';
5 | import { LinkContainer } from 'react-router-bootstrap';
6 | import './UserBadge.css';
7 |
8 | const UserBadge = ({ className, user }) => {
9 | const [show, setShow] = useState(false)
10 |
11 | return (
12 |
13 | {
14 | setShow(!show)
15 | }}>
16 |
17 | {user.osuweb.username}
18 |
19 |
20 |
21 | setShow(false)}>
22 | Uploads
23 |
24 |
25 |
26 | setShow(false)}>
27 | Favourites
28 |
29 |
30 |
31 |
32 | )
33 | }
34 |
35 | UserBadge.propTypes = {
36 | user: PropTypes.object,
37 | }
38 |
39 | export default UserBadge;
40 |
--------------------------------------------------------------------------------
/src/components/bootstrap-osu-collector.js:
--------------------------------------------------------------------------------
1 | import * as ReactBootstrap from 'react-bootstrap'
2 | import styled, { css } from 'styled-components'
3 |
4 | const backgroundColor = props =>
5 | props.$lightbg2 ?
6 | props.theme.primary25
7 | : props.$lightbg ?
8 | props.theme.primary15
9 | : props.theme.primary8
10 |
11 | const backgroundAndBorderColor = props => props.theme.darkMode && css`
12 | background-color: ${backgroundColor};
13 | color: #f8f8f2;
14 | `
15 |
16 | const Card = styled(ReactBootstrap.Card)`
17 | ${backgroundAndBorderColor}
18 | `
19 |
20 | const CardFooter = styled(ReactBootstrap.Card.Footer)`
21 | ${backgroundAndBorderColor}
22 | `
23 |
24 | const CardBody = styled(ReactBootstrap.Card.Body)`
25 | ${backgroundAndBorderColor}
26 | `
27 |
28 | const ListGroupItem = styled(ReactBootstrap.ListGroupItem)`
29 | ${backgroundAndBorderColor}
30 | `
31 |
32 | const Button = styled(ReactBootstrap.Button)`
33 | ${props => props.theme.darkMode && css`
34 | ${props => (props.variant === 'primary' || !props.variant) && css`
35 | background-color: ${props => props.theme.primary50};
36 | border-color: ${props => props.theme.primary};
37 | color: #f8f8f2;
38 | `}
39 | `}
40 | `
41 |
42 | const ModalHeader = styled(ReactBootstrap.Modal.Header)`
43 | ${backgroundAndBorderColor}
44 | `
45 |
46 | const ModalBody = styled(ReactBootstrap.Modal.Body)`
47 | ${backgroundAndBorderColor}
48 | `
49 |
50 | export * from 'react-bootstrap'
51 | export {
52 | Button,
53 | Card,
54 | CardBody,
55 | CardFooter,
56 | ListGroupItem,
57 | ModalHeader,
58 | ModalBody
59 | }
--------------------------------------------------------------------------------
/src/components/common/CollectionList.js:
--------------------------------------------------------------------------------
1 | import { Col, Container, Spinner } from '../bootstrap-osu-collector';
2 | import InfiniteScroll from 'react-infinite-scroll-component';
3 | import ReactPlaceholder from 'react-placeholder/lib';
4 | import CollectionCard from './CollectionCard';
5 |
6 | const CollectionList = ({ collections, hasMore, loadMore }) => {
7 | return (
8 |
9 |
15 |
16 |
17 | }
18 | endMessage={
19 |
20 | Nothing more to show.
21 |
22 | }
23 | className='row'
24 | >
25 | {collections.map((collection, i) =>
26 |
27 |
34 | {collection &&
35 |
36 | }
37 |
38 |
39 | )}
40 |
41 |
42 | )
43 | }
44 |
45 | export default CollectionList;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "osucollector",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@paypal/react-paypal-js": "^7.5.0",
7 | "@stripe/react-stripe-js": "^1.4.1",
8 | "@stripe/stripe-js": "^1.17.1",
9 | "@testing-library/jest-dom": "^5.14.1",
10 | "@testing-library/react": "^11.2.7",
11 | "@testing-library/user-event": "^12.8.3",
12 | "axios": "^0.21.4",
13 | "bootstrap": "^5.0.2",
14 | "colord": "^2.9.1",
15 | "config": "^3.3.6",
16 | "country-flag-icons": "^1.4.6",
17 | "md5": "^2.3.0",
18 | "moment": "^2.29.1",
19 | "prop-types": "^15.7.2",
20 | "query-string": "^7.0.1",
21 | "react": "^17.0.2",
22 | "react-bootstrap": "^1.6.1",
23 | "react-bootstrap-floating-label": "^1.6.0",
24 | "react-bootstrap-icons": "^1.5.0",
25 | "react-confetti": "^6.0.1",
26 | "react-dom": "^17.0.2",
27 | "react-dropzone": "^11.3.4",
28 | "react-google-charts": "^3.0.15",
29 | "react-icons": "^4.2.0",
30 | "react-infinite-scroll-component": "^6.1.0",
31 | "react-json-view": "^1.21.3",
32 | "react-placeholder": "^4.1.0",
33 | "react-responsive": "^9.0.0-beta.4",
34 | "react-router-bootstrap": "^0.25.0",
35 | "react-router-dom": "^5.2.0",
36 | "react-scripts": "4.0.3",
37 | "react-svg": "^14.0.11",
38 | "react-truncate": "^2.4.0",
39 | "react-use": "^17.3.1",
40 | "styled-components": "^5.3.3",
41 | "web-vitals": "^1.1.2"
42 | },
43 | "scripts": {
44 | "start": "react-scripts start",
45 | "build": "react-scripts build",
46 | "eject": "react-scripts eject"
47 | },
48 | "eslintConfig": {
49 | "extends": [
50 | "react-app",
51 | "react-app/jest"
52 | ]
53 | },
54 | "browserslist": {
55 | "production": [
56 | ">0.2%",
57 | "not dead",
58 | "not op_mini all"
59 | ],
60 | "development": [
61 | "last 1 chrome version",
62 | "last 1 firefox version",
63 | "last 1 safari version"
64 | ]
65 | },
66 | "proxy": "https://osucollector.com",
67 | "devDependencies": {
68 | "eslint": "^7.32.0",
69 | "eslint-plugin-react": "^7.24.0"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/common/ModeCounters.js:
--------------------------------------------------------------------------------
1 | import osuPng from './mode-osu.png'
2 | import taikoPng from './mode-taiko.png'
3 | import maniaPng from './mode-mania.png'
4 | import catchPng from './mode-catch.png'
5 | import { Image } from '../bootstrap-osu-collector'
6 | import styled, { css } from 'styled-components'
7 |
8 | const ModeImage = styled(Image)`
9 | width: 18px;
10 | height: auto;
11 | ${props => props.theme.darkMode ? css`
12 | filter: invert(1) opacity(${props => props.muted ? '10%' : '100%'})
13 | ` : css`
14 | filter: invert(0) opacity(${props => props.muted ? '20%' : '100%'})
15 | `}
16 | `
17 |
18 | const ModeLabel = styled.small`
19 | ${props => props.theme.darkMode ? css`
20 | color: ${props => props.muted ? '#444' : '#ccc'}
21 | ` : css`
22 | color: ${props => props.muted ? '#ccc' : '#000'}
23 | `}
24 | `
25 |
26 | function ModeCounters({ collection, className }) {
27 |
28 | return (
29 |
30 |
31 | {[
32 | [osuPng, 'osu'],
33 | [taikoPng, 'taiko'],
34 | [maniaPng, 'mania'],
35 | [catchPng, 'fruits']
36 | ].map(([modePng, mode]) => {
37 | let muted = true
38 | if (collection.modes && collection.modes[mode]) {
39 | muted = false
40 | }
41 | return (
42 |
43 |
49 |
50 | {muted ? 0 : collection.modes[mode]}
51 |
52 |
53 | )
54 | })}
55 |
56 |
57 | )
58 | }
59 |
60 | export default ModeCounters
61 |
--------------------------------------------------------------------------------
/src/components/recent/Recent.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { Card, Alert, Container } from '../bootstrap-osu-collector';
3 | import { getRecentCollections } from '../../utils/api'
4 | import CollectionList from '../common/CollectionList';
5 |
6 | function Recent() {
7 |
8 | const [collectionPage, setCollectionPage] = useState(null);
9 | const [collections, setCollections] = useState(new Array(18).fill(null));
10 | const [error, setError] = useState(null)
11 |
12 | useEffect(() => {
13 | let cancel
14 | getRecentCollections(undefined, 18, c => cancel = c).then(_collectionPage => {
15 | setCollectionPage(_collectionPage)
16 | setCollections(_collectionPage.collections)
17 | }).catch(console.log)
18 | return cancel
19 | }, [])
20 |
21 | const loadMore = async () => {
22 | try {
23 | const _collectionPage = await getRecentCollections(collectionPage.nextPageCursor, 18)
24 | setCollectionPage(_collectionPage)
25 | setCollections([...collections, ..._collectionPage.collections])
26 | } catch (err) {
27 | setError(err)
28 | }
29 | }
30 |
31 | return (
32 |
33 |
34 |
35 |
36 | Recent Collections
37 |
38 | {error ?
39 |
40 |
41 | Sorry, an error occurred with the server. Please try refreshing the page. Error details:
42 |
43 | {error.toString()}
44 |
45 | :
46 |
51 | }
52 |
53 |
54 |
55 | )
56 | }
57 |
58 | export default Recent;
--------------------------------------------------------------------------------
/src/components/login/EnterOtp.js:
--------------------------------------------------------------------------------
1 | import { Card, Container, FormControl } from '../bootstrap-osu-collector'
2 | import md5 from 'md5'
3 | import PropTypes from 'prop-types'
4 | import * as api from '../../utils/api'
5 | import { useHistory } from 'react-router-dom'
6 |
7 | function EnterOtp({ authX, setUser }) {
8 | const history = useHistory();
9 |
10 | const onOtpChanged = async (event) => {
11 | const inputString = event.target.value
12 | if (inputString.length == 0 || inputString.length > 4) {
13 | console.log('inputString: ' + inputString)
14 | return
15 | }
16 |
17 | const otp = Number(inputString)
18 | if (isNaN(otp) || !otp) {
19 | console.log('otp: ' + otp)
20 | return
21 | }
22 |
23 | const y = md5(authX)
24 | console.log('otp: ' + otp)
25 | console.log('x: ' + authX)
26 | console.log('y: ' + y)
27 |
28 | // Submit
29 | const res = await api.submitOtp(otp, y)
30 | console.log(res.status)
31 | if (res.status === 200) {
32 | console.log('Logged in!')
33 | // Get user
34 | const user = await api.getOwnUser();
35 | setUser(user);
36 | history.push('/')
37 | } else if (res.status === 440) {
38 | alert('Login expired, please try to log in again.')
39 | console.log('Login expired, please try to log in again.')
40 | history.push('/')
41 | } else {
42 | console.log('OTP auth failed.')
43 | }
44 | }
45 |
46 | return (
47 |
48 |
49 | One time password
50 |
51 |
After authenticating through the osu! website, osu!Collector should show you a one time password.
52 | Please enter it here to finish logging in.
53 |
54 |
62 |
63 |
64 | )
65 | }
66 |
67 | EnterOtp.propTypes = {
68 | authX: PropTypes.string
69 | }
70 |
71 | export default EnterOtp
72 |
--------------------------------------------------------------------------------
/src/utils/misc.js:
--------------------------------------------------------------------------------
1 | const truncate = (inputString, length) => inputString.length > length ?
2 | inputString.substring(0, length) + '...' :
3 | inputString
4 |
5 | const secondsToHHMMSS = (sec_num) => {
6 | var hours = Math.floor(sec_num / 3600)
7 | var minutes = Math.floor((sec_num - (hours * 3600)) / 60)
8 | var seconds = sec_num - (hours * 3600) - (minutes * 60)
9 |
10 | if (hours < 10) { hours = "0" + hours }
11 | if (minutes < 10 && hours > 0) { minutes = "0" + minutes }
12 | if (seconds < 10) { seconds = "0" + seconds }
13 | return hours > 0
14 | ? hours + ':' + minutes + ':' + seconds
15 | : minutes + ':' + seconds
16 | }
17 |
18 | const clamp = function (num, min, max) {
19 | return Math.min(Math.max(num, min), max)
20 | }
21 |
22 | const bpmToColor = (bpm, darkMode) => {
23 | const _bpm = clamp(Math.floor(bpm / 10) * 10, 150, 300)
24 | if (_bpm == 150) return '#93e2ff'
25 | if (_bpm == 160) return '#80dbff'
26 | if (_bpm == 170) return '#6bd3fe'
27 | if (_bpm == 180) return '#55cbff'
28 | if (_bpm == 190) return '#39c3ff'
29 | if (_bpm == 200) return '#00bbff'
30 | if (_bpm == 210) return '#00a8ea'
31 | if (_bpm == 220) return '#0095d6'
32 | if (_bpm == 230) return '#0082c2'
33 | if (_bpm == 240) return '#0070ae'
34 | if (_bpm == 250) return '#005f9b'
35 | if (_bpm == 260) return '#004e88'
36 | if (_bpm == 270) return '#003e76'
37 | if (_bpm == 280) return '#002e64'
38 | if (_bpm == 290) return '#002052'
39 | if (_bpm == 300) return darkMode ? '#fff' : '#000000'
40 | return '#000000'
41 | }
42 |
43 | const starToColor = (star, darkMode = false) => {
44 | const _star = clamp(Math.floor(star), 1, 10)
45 | if (_star == 1) return '#6EFF79'
46 | if (_star == 2) return '#4FC0FF'
47 | if (_star == 3) return '#F8DA5E'
48 | if (_star == 4) return '#FF7F68'
49 | if (_star == 5) return '#FF4E6F'
50 | if (_star == 6) return '#A653B0'
51 | if (_star == 7) return '#3B38B2'
52 | if (_star == 8) return darkMode ? '#fff' : '#000000'
53 | if (_star == 9) return darkMode ? '#fff' : '#000000'
54 | if (_star == 10) return darkMode ? '#fff' : '#000000'
55 | return darkMode ? '#fff' : '#000000'
56 | }
57 |
58 | const useFallbackImg = (ev, fallbackImg) => {
59 | if (ev.target.src === fallbackImg) {
60 | return
61 | }
62 | ev.target.err = null
63 | ev.target.src = fallbackImg
64 | }
65 |
66 | export {
67 | truncate,
68 | secondsToHHMMSS,
69 | clamp,
70 | bpmToColor,
71 | starToColor,
72 | useFallbackImg
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/common/EditableTextbox.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Button, Card } from '../bootstrap-osu-collector'
3 | import styled, { css } from 'styled-components'
4 | import './EditableTextbox.css'
5 |
6 | const ClickableCard = styled(Card)`
7 | ${props => props.$isClickable && css`
8 | cursor: pointer;
9 | &:hover {
10 | background-color: ${props => props.theme.darkMode ? '#ffffff20' : '#00000020'};
11 | }
12 | `}
13 | `
14 |
15 | function EditableTextbox({ value, isEditable, submit }) {
16 | const [editing, setEditing] = useState(false)
17 | const [unsavedValue, setUnsavedValue] = useState(value)
18 |
19 | const startEdit = () => {
20 | if (isEditable) {
21 | setEditing(true)
22 | }
23 | }
24 |
25 | const cancelEdit = () => {
26 | setEditing(false)
27 | }
28 |
29 | const finishEdit = () => {
30 | setEditing(false)
31 | submit(unsavedValue)
32 | }
33 |
34 | if (editing) {
35 | return (
36 |
65 | )
66 | } else {
67 | return (
68 |
75 | {value ?
76 | {value}
77 | :
78 | no description
79 | }
80 |
81 | )
82 | }
83 | }
84 |
85 | export default EditableTextbox
86 |
--------------------------------------------------------------------------------
/src/components/common/FIleUpload.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/prop-types */
2 | import React, { Component } from 'react'
3 | class DragAndDrop extends Component {
4 | constructor(props) {
5 | super(props);
6 | this.state = {
7 | drag: false
8 | }
9 | this.dropRef = React.createRef();
10 | }
11 |
12 | handleDrag(e) {
13 | e.preventDefault();
14 | e.stopPropagation();
15 | }
16 | handleDragIn(e) {
17 | e.preventDefault();
18 | e.stopPropagation();
19 | this.dragCounter++
20 | if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
21 | this.setState({drag: true})
22 | }
23 | }
24 | handleDragOut(e) {
25 | e.preventDefault();
26 | e.stopPropagation();
27 | this.dragCounter--
28 | if (this.dragCounter === 0) {
29 | this.setState({drag: false});
30 | }
31 | }
32 | handleDrop(e) {
33 | e.preventDefault();
34 | e.stopPropagation();
35 | this.setState({drag: false});
36 | if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
37 | this.props.handleDrop(e.dataTransfer.files);
38 | e.dataTransfer.clearData()
39 | this.dragCounter = 0
40 | }
41 | }
42 | componentDidMount() {
43 | let div = this.dropRef.current
44 | div.addEventListener('dragenter', this.handleDragIn.bind(this))
45 | div.addEventListener('dragleave', this.handleDragOut.bind(this))
46 | div.addEventListener('dragover', this.handleDrag.bind(this))
47 | div.addEventListener('drop', this.handleDrop.bind(this))
48 | }
49 | componentWillUnmount() {
50 | let div = this.dropRef.current
51 | div.removeEventListener('dragenter', this.handleDragIn.bind(this))
52 | div.removeEventListener('dragleave', this.handleDragOut.bind(this))
53 | div.removeEventListener('dragover', this.handleDrag.bind(this))
54 | div.removeEventListener('drop', this.handleDrop.bind(this))
55 | }
56 | render() {
57 | return (
58 |
62 | {this.state.dragging &&
63 |
89 | }
90 | {this.props.children}
91 |
92 | )
93 | }
94 | }
95 | export default DragAndDrop
--------------------------------------------------------------------------------
/src/utils/collectionsDb.js:
--------------------------------------------------------------------------------
1 | import { decodeULEB128 } from './uleb128'
2 | import { decodeUtf8 } from './utf8-decoder'
3 |
4 | // Returns array of:
5 | /*
6 | {
7 | name: string,
8 | beatmapChecksums: string[]
9 | }
10 | */
11 | function parseCollectionDb(buffer) {
12 | let collections = []
13 | let offset = 0
14 |
15 | // skip version (Int32, 4 bytes)
16 | offset += 4
17 |
18 | // read number of collections (Int32, 4 bytes)
19 | let numCollections = readUnalignedInt32(buffer, offset)
20 | offset += 4
21 |
22 | for (let i = 0; i < numCollections; i++) {
23 |
24 | // read collection name (peppy string)
25 | let [collectionName, bytesParsed] = parsePeppyString(buffer, offset)
26 | offset += bytesParsed
27 |
28 | // read number of beatmaps in collection (Int32)
29 | // let numBeatmaps: number = new Uint32Array(buffer, offset)[0]
30 | let numBeatmaps = readUnalignedInt32(buffer, offset)
31 | offset += 4
32 |
33 | // read each beatmap MD5 hash
34 | let beatmapChecksums = []
35 | for (let i = 0; i < numBeatmaps; i++) {
36 | let [beatmapMd5, bytesParsed] = parsePeppyString(buffer, offset)
37 | beatmapChecksums.push(beatmapMd5)
38 | offset += bytesParsed
39 | }
40 |
41 | collections.push({
42 | name: collectionName,
43 | beatmapChecksums: beatmapChecksums
44 | })
45 | }
46 | return collections
47 | }
48 |
49 | function readUnalignedInt32(source, position) {
50 | let readBuffer = new ArrayBuffer(4)
51 | let u8Dest = new Uint8Array(readBuffer, 0, 4); // Create 8-bit view of the array
52 | let u8Src = new Uint8Array(source, position, 4); // Create 8-bit view of the array
53 | u8Dest.set(u8Src, 0); // Copy bytes one by one
54 | return new Uint32Array(readBuffer)[0]
55 | }
56 |
57 | // Peppy's binary string encoding according to https://osu.ppy.sh/wiki/cs/osu!_File_Formats/Db_(file_format)
58 | /* Has three parts; a single byte which will be either 0x00, indicating that the
59 | * next two parts are not present, or 0x0b (decimal 11), indicating that the next
60 | * two parts are present. If it is 0x0b, there will then be a ULEB128,
61 | * representing the byte length of the following string, and then the string
62 | * itself, encoded in UTF-8. */
63 | // Returns [result: string, bytesRead: number], where endOffset is the position after the last byte of the string
64 | function parsePeppyString(buffer, startOffset) {
65 | let offset = startOffset
66 | if (new Uint8Array(buffer, offset)[0] === 0x00) {
67 | return ['', 1]
68 | }
69 | else if (new Uint8Array(buffer, offset)[0] === 0x0b) {
70 | offset += 1
71 | const uleb128 = decodeULEB128(new Uint8Array(buffer, offset), 0)
72 | const stringLength = Number(uleb128[0])
73 | const ulebLength = uleb128[1]
74 | offset += ulebLength
75 |
76 | const result = decodeUtf8(new Uint8Array(buffer, offset, stringLength))
77 | offset += stringLength
78 | let bytesParsed = offset - startOffset
79 | return [result, bytesParsed]
80 | }
81 | throw new Error('Tried to read an invalid peppy string')
82 | }
83 |
84 | export { parseCollectionDb }
--------------------------------------------------------------------------------
/src/components/users/UserCard.js:
--------------------------------------------------------------------------------
1 | import { Card, CardBody } from '../bootstrap-osu-collector';
2 | import { Button } from 'react-bootstrap';
3 | import { LinkContainer } from 'react-router-bootstrap'
4 | import './UserCard.css'
5 | import Flags from 'country-flag-icons/react/3x2'
6 | import { useFallbackImg } from '../../utils/misc';
7 | import usercoverfallback from './usercoverfallback.jpg'
8 | import styled from 'styled-components'
9 |
10 | const Username = styled.a`
11 | text-decoration: none;
12 | font-size: 1.3em;
13 | color: ${props => props.theme.darkMode ? '#fff' : '#000'};
14 |
15 | &:hover {
16 | text-decoration: none;
17 | }
18 | `
19 |
20 | const UserCard = ({ user }) => {
21 | const userFavouritesButton = (user) => {
22 | const disabled = !user.favourites || !user.favourites.length > 0
23 | return (
24 |
25 |
29 | Favourites: {user.favourites ? user.favourites.length : 0}
30 |
31 |
32 | )
33 | }
34 |
35 | const Flag = Flags[user.osuweb.country_code.toUpperCase()]
36 |
37 | const userUploadsButton = (user) => {
38 | const disabled = !user.uploads || !user.uploads.length > 0
39 | return (
40 |
41 |
45 | Uploads: {user.uploads ? user.uploads.length : 0}
46 |
47 |
48 | )
49 | }
50 |
51 | return (
52 |
53 |
54 | useFallbackImg(ev, usercoverfallback)}
58 | />
59 |
60 |
61 |
62 |
63 |
64 |
65 | {user.osuweb.username}
66 |
67 |
68 |
#{user.osuweb.statistics.global_rank}
69 |
70 |
71 | #{user.osuweb.statistics.country_rank}
72 |
73 |
74 |
75 |
76 |
77 |
78 | {userFavouritesButton(user)}
79 | {userUploadsButton(user)}
80 |
81 |
82 |
83 |
84 | )
85 | }
86 |
87 | export default UserCard
88 |
--------------------------------------------------------------------------------
/src/components/popular/Popular.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { useHistory } from 'react-router-dom';
3 | import { Card, Button, Container } from '../bootstrap-osu-collector'
4 | import { getPopularCollections } from '../../utils/api'
5 | import { useQuery } from '../../utils/hooks'
6 | import CollectionList from '../common/CollectionList';
7 |
8 | function Popular() {
9 |
10 | const [range, setRange] = useState(null)
11 | const [collectionPage, setCollectionPage] = useState(null)
12 | const [collections, setCollections] = useState(new Array(18).fill(null))
13 | const query = useQuery()
14 | const history = useHistory()
15 |
16 | useEffect(() => {
17 | const queryParamRange = query.get('range')
18 | setRange(queryParamRange || 'alltime')
19 | }, [])
20 |
21 | useEffect(() => {
22 | if (!range) {
23 | return
24 | }
25 | // retrieve the first page of results after date range is changed
26 | let cancel
27 | getPopularCollections(range, undefined, 18, c => cancel = c).then(_collectionPage => {
28 | setCollectionPage(_collectionPage)
29 | setCollections(_collectionPage.collections)
30 | }).catch(console.log)
31 | return cancel
32 | }, [range])
33 |
34 | const loadMore = async () => {
35 | try {
36 | const _collectionPage = await getPopularCollections(range, collectionPage.nextPageCursor, 18)
37 | setCollectionPage(_collectionPage)
38 | setCollections([...collections, ..._collectionPage.collections])
39 | } catch (err) {
40 | console.log(err)
41 | }
42 | }
43 |
44 | const dateRanges = [
45 | { range: 'today', label: 'today' },
46 | { range: 'week', label: 'this week' },
47 | { range: 'month', label: 'this month' },
48 | { range: 'year', label: 'this year' },
49 | { range: 'alltime', label: 'all time' }
50 | ]
51 |
52 | return (
53 |
54 |
55 |
56 |
57 |
58 |
59 | Popular Collections
60 |
61 |
62 | {dateRanges.map((opt, i) =>
63 | {
68 | setCollectionPage(null)
69 | setCollections(new Array(18).fill(null))
70 | history.push(`/popular?range=${opt.range}`)
71 | setRange(opt.range)
72 | }}
73 | variant={range === opt.range ? 'danger' : 'outline-secondary'}
74 | >
75 | {opt.label}
76 |
77 | )}
78 |
79 |
80 |
85 |
86 |
87 |
88 | )
89 | }
90 |
91 | export default Popular
--------------------------------------------------------------------------------
/src/components/users/Users.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import { useState, useEffect } from 'react';
3 | import InfiniteScroll from 'react-infinite-scroll-component';
4 | import { Alert, Card, Container, Col, Spinner, Pagination } from '../bootstrap-osu-collector';
5 | import { getUsers } from '../../utils/api'
6 | import ReactPlaceholder from 'react-placeholder/lib';
7 | import UserCard from './UserCard'
8 |
9 | function Users() {
10 |
11 | const [userResults, setUserResults] = useState(null);
12 | const [users, setUsers] = useState(new Array(24).fill(null));
13 | const [error, setError] = useState(null);
14 |
15 | // get query params on initial page load
16 | useEffect(() => {
17 | let cancel
18 | getUsers(1, 24, c => cancel = c).then(_userResults => {
19 | setUserResults(_userResults)
20 | setUsers(_userResults.users)
21 | }).catch(console.log)
22 | return cancel
23 | }, [])
24 |
25 | const loadMore = async () => {
26 | try {
27 | const _userResults = await getUsers(userResults.nextPage, 24)
28 | setUserResults(_userResults)
29 | setUsers([...users, ..._userResults.users])
30 | } catch (err) {
31 | setError(err)
32 | }
33 | }
34 |
35 | return (
36 |
37 |
38 |
39 |
40 | Users
41 |
42 | {error ?
43 |
44 |
45 | Sorry, an error occurred with the server. Please try refreshing the page. Error details:
46 |
47 | {error.toString()}
48 |
49 | :
50 |
51 |
57 |
58 |
59 | }
60 | endMessage={
61 |
62 | Nothing more to show.
63 |
64 | }
65 | className='row'
66 | >
67 | {users.map((user, i) =>
68 |
69 |
76 | {user &&
77 |
78 | }
79 |
80 |
81 | )}
82 |
83 |
84 | }
85 |
86 |
87 |
88 | )
89 | }
90 |
91 | export default Users;
--------------------------------------------------------------------------------
/src/components/users/UserUploads.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { Card, Container, Image } from '../bootstrap-osu-collector';
3 | import CollectionList from '../common/CollectionList';
4 | import * as api from '../../utils/api'
5 | import ReactPlaceholder from 'react-placeholder/lib';
6 |
7 | function UserUploads() {
8 |
9 | const [user, setUser] = useState(null);
10 | const [collections, setCollections] = useState(new Array(3).fill(null));
11 |
12 | // run this code on initial page load
13 | useEffect(async () => {
14 |
15 | // get user id from path, eg. /users/123/uploads
16 | const match = window.location.pathname.match(/\/users\/(\d+)\/uploads/g)
17 | if (!match) {
18 | alert('User not found.')
19 | return
20 | }
21 | const userId = Number(match[0].replace('/users/', '').replace('/uploads', '').trim())
22 |
23 | // get user from database
24 | const user = await api.getUser(userId)
25 | if (user)
26 | setUser(user)
27 | else
28 | alert(`user with id ${userId} not found`)
29 | // eslint-disable-next-line react-hooks/exhaustive-deps
30 | }, [])
31 |
32 | // get collections when user changes
33 | useEffect(() => {
34 | if (!user)
35 | return
36 | let cancel
37 | api.getUserUploads(user.id, c => cancel = c).then(collections => {
38 | setCollections(collections)
39 | }).catch(console.log)
40 | return cancel
41 | }, [user])
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
56 |
65 |
66 |
72 | {user?.osuweb?.username}'s Uploads
73 |
74 |
75 |
82 | {collections.length} collections
83 |
84 |
85 | 0}
89 | />
90 |
91 |
92 |
93 | )
94 | }
95 |
96 | export default UserUploads;
--------------------------------------------------------------------------------
/src/components/users/UserFavourites.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { Card, Container, Image } from '../bootstrap-osu-collector';
3 | import { getUserFavourites } from '../../utils/api'
4 | import CollectionList from '../common/CollectionList';
5 | import * as api from '../../utils/api'
6 | import ReactPlaceholder from 'react-placeholder/lib';
7 |
8 | function UserFavourites() {
9 |
10 | const [user, setUser] = useState(null);
11 | const [collections, setCollections] = useState(new Array(3).fill(null));
12 |
13 | // run this code on initial page load
14 | useEffect(async () => {
15 |
16 | // get user id from path, eg. /users/123/favourites
17 | const match = window.location.pathname.match(/\/users\/(\d+)\/favourites/g)
18 | if (!match) {
19 | alert('User not found.')
20 | return
21 | }
22 | const userId = Number(match[0].replace('/users/', '').replace('/favourites', '').trim())
23 |
24 | // get user from database
25 | const user = await api.getUser(userId)
26 | if (user)
27 | setUser(user)
28 | else
29 | alert(`user with id ${userId} not found`)
30 | // eslint-disable-next-line react-hooks/exhaustive-deps
31 | }, [])
32 |
33 | // run this code when page changes
34 | useEffect(() => {
35 | if (!user)
36 | return
37 | let cancel
38 | getUserFavourites(user.id, c => cancel = c).then(collections => {
39 | setCollections(collections)
40 | })
41 | return cancel
42 | }, [user])
43 |
44 | return (
45 |
46 |
47 |
48 |
49 |
50 |
57 |
66 |
67 |
73 | {user?.osuweb?.username}'s Favourites
74 |
75 |
76 |
83 | {collections.length} collections
84 |
85 |
86 | 0}
90 | />
91 |
92 |
93 |
94 | )
95 | }
96 |
97 | export default UserFavourites;
--------------------------------------------------------------------------------
/src/components/common/CollectionCard.js:
--------------------------------------------------------------------------------
1 | import { useContext, useState } from 'react';
2 | import { Card, Image, ListGroup, ListGroupItem } from '../bootstrap-osu-collector'
3 | import moment from 'moment'
4 | import './CollectionCard.css'
5 | import { LinkContainer } from 'react-router-bootstrap'
6 | import Truncate from 'react-truncate'
7 | import { starToColor } from '../../utils/misc'
8 | import BarGraph from './BarGraph';
9 | import styled, { ThemeContext } from 'styled-components'
10 | import ModeCounters from './ModeCounters';
11 |
12 | const GraphContainer = styled(Card.Body)`
13 | cursor: pointer;
14 | background-color: ${props => props.theme.darkMode ? '#121212' : '#eee'};
15 | `
16 |
17 | function CollectionCard({ collection }) {
18 |
19 | const theme = useContext(ThemeContext)
20 |
21 | const [hovered, setHovered] = useState(false)
22 |
23 | const relativeDate = moment.unix(collection.dateUploaded._seconds).fromNow()
24 | const heartColour = collection.favouritedByUser ? 'red' : 'grey'
25 | const difficultySpread = collection.difficultySpread
26 | ? collection.difficultySpread
27 | : {
28 | 1: 0,
29 | 2: 0,
30 | 3: 0,
31 | 4: 0,
32 | 5: 0,
33 | 6: 0,
34 | 7: 0,
35 | 8: 0,
36 | 9: 0,
37 | 10: 0
38 | }
39 |
40 | return (
41 |
118 | )
119 | }
120 |
121 | export default CollectionCard
122 |
--------------------------------------------------------------------------------
/src/components/payments/Checkout.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import { useState } from 'react'
3 | import { Alert, Button, Card, Col, Container, Form, FormControl, Row, Spinner } from '../bootstrap-osu-collector'
4 | import FloatingLabel from 'react-bootstrap-floating-label'
5 | import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'
6 | import { ReactSVG } from 'react-svg'
7 | import * as api from '../../utils/api'
8 | import { validateEmail } from '../../utils/helpers'
9 | import { useHistory } from 'react-router'
10 |
11 | function Checkout() {
12 | const [processing, setProcessingTo] = useState(false)
13 | const [checkoutError, setCheckoutError] = useState()
14 | const [cardError, setCardError] = useState(false)
15 |
16 | const history = useHistory()
17 | const stripe = useStripe()
18 | const elements = useElements()
19 |
20 | const handleCardDetailsChange = ev => {
21 | if (ev.error) {
22 | setCheckoutError(ev.error.message)
23 | setCardError(true)
24 | } else {
25 | setCheckoutError()
26 | setCardError(false)
27 | }
28 | }
29 |
30 | const handleFormSubmit = async (ev) => {
31 | ev.preventDefault()
32 |
33 | const email = ev.target[0].value
34 | if (!validateEmail(email)) {
35 | setCheckoutError('That is not a valid email')
36 | setProcessingTo(0)
37 | return
38 | }
39 |
40 | const cardElement = elements.getElement('card')
41 |
42 | // Create customer by sending request to backend
43 | setProcessingTo(1)
44 | const createCustomerResponse = await api.createCustomer(email)
45 | console.log(createCustomerResponse)
46 |
47 | // Create subscription by sending request to backend
48 | let createSubscriptionResponse
49 | try {
50 | setProcessingTo(2)
51 | createSubscriptionResponse = await api.createSubscription()
52 | } catch (err) {
53 | setCheckoutError(err.message)
54 | setProcessingTo(false)
55 | return
56 | }
57 | const clientSecret = createSubscriptionResponse.clientSecret
58 | console.log(createSubscriptionResponse)
59 |
60 | // Collect the payment via Stripe
61 | setProcessingTo(3)
62 | const result = await stripe.confirmCardPayment(clientSecret, {
63 | payment_method: {
64 | card: cardElement,
65 | billing_details: {
66 | email: email
67 | }
68 | },
69 | setup_future_usage: 'off_session'
70 | })
71 | if (result.error) {
72 | setCheckoutError(result.error.message)
73 | setProcessingTo(0)
74 | return
75 | }
76 | history.push('/payments/success')
77 | }
78 |
79 | const cardElementOpts = {
80 | iconStyle: 'solid',
81 | style: {
82 | base: {
83 | color: '#000',
84 | fontSize: '16px'
85 | // iconColor: '#aaa',
86 | // '::placeholder': {
87 | // color: '#999'
88 | // }
89 | },
90 | // invalid: {
91 | // iconColor: '#FFC7EE',
92 | // color: '#FFC7EE'
93 | // },
94 | // complete: {
95 | // iconColor: '#cbf4c9'
96 | // }
97 | },
98 | // hidePostalCode: true
99 | }
100 |
101 | return (
102 |
103 |
104 | {/* {process.env.NODE_ENV === 'production' &&
105 |
106 | you shouldnt be here unless youre a dev
107 |
108 | } */}
109 |
110 |
111 |
112 | Desktop Client Subscription
113 | $1.99 per month
114 | auto-renewing subscription (you may cancel at any time)
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | Pay with card
124 |
125 |
157 |
158 | {
161 | svg.setAttribute('style', 'width: 128px')
162 | }} />
163 |
164 |
165 |
166 |
167 | )
168 | }
169 |
170 | export default Checkout
171 |
--------------------------------------------------------------------------------
/src/components/all/All.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { Card, Container, Image } from '../bootstrap-osu-collector';
3 | import { Search } from 'react-bootstrap-icons';
4 | import { useHistory } from 'react-router-dom';
5 | import { searchCollections } from '../../utils/api'
6 | import { useQuery } from '../../utils/hooks';
7 | import CollectionList from '../common/CollectionList';
8 | import SortButton from '../common/SortButton';
9 | import osuPng from '../common/mode-osu.png'
10 | import taikoPng from '../common/mode-taiko.png'
11 | import maniaPng from '../common/mode-mania.png'
12 | import catchPng from '../common/mode-catch.png'
13 |
14 | function All({ searchText, setSearchText }) {
15 |
16 | const [collectionPage, setCollectionPage] = useState(null);
17 | const [collections, setCollections] = useState(new Array(18).fill(null));
18 | const [queryOpts, setQueryOpts] = useState(null)
19 | const query = useQuery();
20 | const history = useHistory();
21 |
22 | // get query params on initial page load
23 | useEffect(() => {
24 | setSearchText(query.get('search') || '');
25 | setQueryOpts({
26 | sortBy: query.get('sortBy') || '_text_match',
27 | orderBy: query.get('orderBy') || 'desc'
28 | })
29 | }, [])
30 |
31 | // Get first page of results
32 | useEffect(() => {
33 | if (searchText === null || queryOpts === null) {
34 | return
35 | }
36 | let cancel
37 | setCollectionPage(null)
38 | setCollections(new Array(18).fill(null));
39 | searchCollections(
40 | searchText,
41 | undefined, // retrieve first page
42 | 18,
43 | queryOpts.sortBy,
44 | queryOpts.orderBy,
45 | c => cancel = c
46 | ).then(_collectionPage => {
47 | setCollectionPage(_collectionPage);
48 | setCollections(_collectionPage.collections)
49 | const qs = []
50 | if (searchText !== null && searchText !== '') {
51 | qs.push(`search=${searchText}`)
52 | }
53 | if (queryOpts.sortBy) {
54 | qs.push(`sortBy=${queryOpts.sortBy}`)
55 | }
56 | if (queryOpts.orderBy) {
57 | qs.push(`orderBy=${queryOpts.orderBy}`)
58 | }
59 | history.push(`/all?${qs.join('&')}`)
60 | }).catch(console.log)
61 | return cancel
62 | }, [searchText, queryOpts])
63 |
64 | const setSortBy = sortBy => {
65 | if (queryOpts.sortBy === sortBy) {
66 | if (sortBy !== '_text_match') {
67 | setQueryOpts({
68 | ...queryOpts,
69 | orderBy: queryOpts.orderBy === 'asc' ? 'desc' : 'asc'
70 | })
71 | }
72 | } else {
73 | setQueryOpts({
74 | ...queryOpts,
75 | sortBy: sortBy,
76 | orderBy: 'desc',
77 | filterMin: undefined,
78 | filterMax: undefined
79 | })
80 | }
81 | }
82 |
83 | const loadMore = async () => {
84 | try {
85 | const _collectionPage = await searchCollections(
86 | searchText,
87 | collectionPage.nextPageCursor,
88 | 18,
89 | queryOpts.sortBy,
90 | queryOpts.orderBy
91 | )
92 | setCollectionPage(_collectionPage)
93 | setCollections([...collections, ..._collectionPage.collections])
94 | } catch (err) {
95 | console.log(err)
96 | }
97 | }
98 |
99 | return (
100 |
101 |
102 |
103 | {searchText?.length > 0 &&
104 |
{searchText}
105 | }
106 |
107 |
108 |
109 | {collectionPage?.results} results
110 |
111 | {/*
112 | Filter by tags
113 | */}
114 |
115 |
116 |
117 |
118 | Sort by:
119 |
120 | {[
121 | ['_text_match', 'Relevance'],
122 | ['favourites', 'Favourites'],
123 | ['dateUploaded', 'Date']
124 | ].map(([field, label]) =>
125 |
126 | setSortBy(field)}
129 | >
130 | {label}
131 |
132 |
133 | )}
134 | {[
135 | ['osuCount', osuPng],
136 | ['taikoCount', taikoPng],
137 | ['maniaCount', maniaPng],
138 | ['catchCount', catchPng]
139 | ].map(([field, png]) =>
140 |
141 | setSortBy(field)}
144 | >
145 |
150 |
151 |
152 | )}
153 |
154 |
155 |
156 |
161 |
162 |
163 |
164 |
165 |
166 | )
167 | }
168 |
169 | export default All;
--------------------------------------------------------------------------------
/src/components/home/Home.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Card, Col, Container, Row } from '../bootstrap-osu-collector';
3 | import * as api from '../../utils/api'
4 | import './Home.css';
5 | import ReactPlaceholder from 'react-placeholder';
6 | import "react-placeholder/lib/reactPlaceholder.css";
7 | import CollectionCard from '../common/CollectionCard';
8 | import { LinkContainer } from 'react-router-bootstrap';
9 | import { Alert } from 'react-bootstrap';
10 |
11 | function Home() {
12 | const [metadata, setMetadata] = useState(null);
13 | const [popular, setPopular] = useState(new Array(6).fill(null));
14 | const [recent, setRecent] = useState(new Array(3).fill(null));
15 |
16 | useEffect(async () => {
17 | let cancel1, cancel2, cancel3
18 | api.getMetadata(c => cancel1 = c).then(setMetadata).catch(console.log)
19 | api.getPopularCollections('week', 1, 6, c => cancel2 = c).then(paginatedCollectionData => {
20 | setPopular(paginatedCollectionData.collections)
21 | }).catch(console.log)
22 | api.getRecentCollections(1, 9, c => cancel3 = c).then(paginatedCollectionData => {
23 | setRecent(paginatedCollectionData.collections)
24 | }).catch(console.log)
25 | return () => {
26 | if (cancel1) cancel1()
27 | if (cancel2) cancel2()
28 | if (cancel3) cancel3()
29 | }
30 | }, [])
31 |
32 | return (
33 |
34 |
35 |
36 | Beatmap downloads are working again. If any issues arise, please visit the osu!Collector discord .
37 |
38 |
39 |
40 | Welcome to osu!Collector!
41 |
42 |
43 | This is a place where you can view beatmap collections uploaded by other players.
44 | It is mainly developed by FunOrange and Mahloola .
45 | {/* {process.env.NODE_ENV !== 'production' && */
46 | (<> If you like the project, consider supporting us to get access to extra features .>)
47 | }
48 |
49 |
50 |
51 |
52 |
53 | Statistics
54 |
55 |
56 |
57 |
58 | Users
59 |
60 |
61 |
62 | {metadata?.userCount}
63 |
64 |
65 |
66 |
67 |
68 | Collections
69 |
70 |
71 |
72 | {metadata?.totalCollections}
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | Popular this week
88 |
89 |
90 |
91 | See all
92 |
93 |
94 |
95 |
96 | {popular?.map((collection, i) => (
97 |
98 |
102 |
103 |
104 |
105 | ))}
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | Recently Uploaded
117 |
118 |
119 |
120 | See all
121 |
122 |
123 |
124 |
125 | {recent?.map((collection, i) => (
126 |
127 |
131 |
132 |
133 |
134 | ))}
135 |
136 |
137 |
138 |
139 |
140 | )
141 | }
142 |
143 | export default Home;
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import { Route, Switch, useHistory } from 'react-router-dom'
2 | import { getOwnUser } from './utils/api.js'
3 | import { useState, useEffect } from 'react'
4 | import { useQuery } from './utils/hooks'
5 | import { css, ThemeProvider } from 'styled-components'
6 | import styled from 'styled-components'
7 | import { colord, extend } from 'colord'
8 | import mixPlugin from "colord/plugins/mix"
9 |
10 | import Home from './components/home/Home'
11 | import Collection from './components/collection/Collection'
12 | import Popular from './components/popular/Popular'
13 | import Recent from './components/recent/Recent'
14 | import NavigationBar from './components/navbar/NavigationBar'
15 | import All from './components/all/All'
16 | import Users from './components/users/Users'
17 | import './App.css'
18 | import UserFavourites from './components/users/UserFavourites.js'
19 | import UserUploads from './components/users/UserUploads.js'
20 | import EnterOtp from './components/login/EnterOtp.js'
21 |
22 | // website imports
23 | import NotFound from './components/notfound/NotFound'
24 | import DesktopClient from './components/client/DesktopClient'
25 | import ShowOtp from './components/login/ShowOtp.js'
26 | import TwitchSuccess from './components/twitchSuccess/TwitchSuccess.js'
27 | import { loadStripe } from "@stripe/stripe-js"
28 | import { Elements } from "@stripe/react-stripe-js"
29 | import Checkout from './components/payments/Checkout.js'
30 | import Success from './components/payments/Success.js'
31 | import { PayPalScriptProvider } from "@paypal/react-paypal-js"
32 |
33 | extend([mixPlugin])
34 |
35 | // const stripePromise = loadStripe("pk_test_51JVjhhKoq9U17mD0sDkdxbLmHsLEvF0eeUUhgaJEeZgG0Iskojm8KV6UQPp4KeccpCU06rDqPmlb1EhMTOy9TrVN001tIYiti9")
36 | const stripePromise = loadStripe("pk_live_51JVjhhKoq9U17mD0DFdbNlJ7dBkPDBZd6lMrLcfd3AfKiuSp7beXY16YpttOc4ZzS4ulVJ7vwSoLeCfe2tTuYnF100TETgqT2M")
37 |
38 | const StyledApp = styled.div`
39 | ${props => props.theme.darkMode && css`
40 | background-color: ${props.theme.primary0};
41 | color: #f8f8f2;
42 | `}
43 | `
44 |
45 | function App() {
46 |
47 | const history = useHistory()
48 |
49 | // undefined (loading) -> [{...} OR null]
50 | const [user, setUser] = useState(undefined)
51 | // searchText is shared between NavigationBar and All
52 | const [searchText, setSearchText] = useState('')
53 | const query = useQuery()
54 |
55 | // For authentication using OTP (react dev environment, electron app)
56 | // eslint-disable-next-line no-unused-vars
57 | const [authX, setAuthX] = useState('')
58 |
59 | // get query params on initial page load
60 | useEffect(async () => {
61 | // store logged in user object in app level state
62 | const user = await getOwnUser()
63 | // undo theme if user is not subscribed
64 | if (!user?.paidFeaturesAccess && currentTheme.darkMode) {
65 | const newTheme = {
66 | ...currentTheme,
67 | darkMode: false
68 | }
69 | setCurrentTheme(newTheme)
70 | localStorage.setItem('theme', JSON.stringify(newTheme))
71 | }
72 | setUser(user)
73 | setSearchText(query.get('search') || '')
74 | // eslint-disable-next-line react-hooks/exhaustive-deps
75 | }, [])
76 |
77 | const theme = {
78 | darkMode: false,
79 | primary: '#86AAFC'
80 | }
81 | for (const i of Array(100).keys()) {
82 | theme['primary' + i] = colord('#121212').mix(theme.primary, i / 100).toHex()
83 | }
84 | const readTheme = () => {
85 | try {
86 | return JSON.parse(localStorage.getItem('theme'))
87 | } catch (err) {
88 | return null
89 | }
90 | }
91 | const [currentTheme, setCurrentTheme] = useState(readTheme() || theme)
92 | const toggleTheme = () => {
93 | if (!user?.paidFeaturesAccess) {
94 | // redirect to /client
95 | history.push('/client')
96 | return
97 | }
98 | const newTheme = {
99 | ...currentTheme,
100 | darkMode: !currentTheme.darkMode
101 | }
102 | setCurrentTheme(newTheme)
103 | localStorage.setItem('theme', JSON.stringify(newTheme))
104 | }
105 |
106 | return (
107 |
115 |
116 |
117 |
118 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 | )
180 | }
181 |
182 | export default App
183 |
--------------------------------------------------------------------------------
/src/components/navbar/UploadModal.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Badge, Button, Card, Form, Spinner, Modal, ModalBody } from '../bootstrap-osu-collector';
3 | import { useHistory } from 'react-router-dom';
4 | import * as api from '../../utils/api';
5 | import './uploadModal.css'
6 | import moment from 'moment'
7 | import { useCallback } from 'react';
8 | import { useDropzone } from 'react-dropzone';
9 | import { parseCollectionDb } from '../../utils/collectionsDb'
10 |
11 | function UploadModal({ uploadModalIsOpen, setUploadModalIsOpen, remoteCollections }) {
12 |
13 | const [selectedCollection, setSelectedCollection] = useState(null);
14 | const [uploading, setUploading] = useState(false);
15 | const history = useHistory();
16 | const [file, setFile] = useState(null);
17 | const [localCollections, setLocalCollections] = useState([]);
18 | const onDrop = useCallback((acceptedFiles) => {
19 | let file = acceptedFiles[0];
20 | setFile(file);
21 | console.log(file);
22 | let reader = new FileReader();
23 | reader.onload = async () => {
24 | setLocalCollections(parseCollectionDb(reader.result));
25 | console.log('collections', localCollections);
26 | }
27 | reader.readAsArrayBuffer(file);
28 | }, [])
29 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
30 |
31 | const onCheck = ({ target }) => {
32 | const { value, checked } = target
33 | setSelectedCollection(checked ? localCollections[value] : null)
34 | }
35 |
36 | const isUploaded = collection => remoteCollections
37 | .map(c => c.name)
38 | .includes(collection?.name)
39 |
40 | const getRemoteCollection = name => {
41 | const c = remoteCollections.find(c => c.name === name)
42 | console.log(c)
43 | return c
44 | }
45 |
46 | const submit = async () => {
47 | if (!selectedCollection) {
48 | return
49 | }
50 | if (selectedCollection.beatmapChecksums.length > 2000) {
51 | alert('This collection is too big (max collection size: 2000)')
52 | return
53 | }
54 | setUploading(true);
55 | try {
56 | const collections = await api.uploadCollections([selectedCollection]);
57 | alert(`Collection uploaded!`);
58 | if (collections.length >= 1) {
59 | history.push(`/collections/${collections[0].id}`);
60 | }
61 | } catch (err) {
62 | alert(err.message + '\n\n' + 'If this is your first time seeing this, you can try uploading the collection again.');
63 | }
64 | setUploading(false);
65 | setUploadModalIsOpen(false);
66 | }
67 |
68 | return (
69 | setUploadModalIsOpen(false)}
72 | size='xl'
73 | centered
74 | >
75 |
76 | 1. Open collection.db
77 | collection.db is a file that contains all of your osu! collections. It is located in your osu! install folder. Example:
78 |
79 | C:\Users\jun\AppData\Local\osu!\collection.db
80 |
81 |
82 |
159 |
160 |
161 | )
162 | }
163 |
164 | export default UploadModal
165 |
--------------------------------------------------------------------------------
/src/components/navbar/NavigationBar.js:
--------------------------------------------------------------------------------
1 | import { Nav, Navbar, Form, FormControl, Button, InputGroup } from '../bootstrap-osu-collector';
2 | import config from '../../config/config'
3 | import { LinkContainer } from 'react-router-bootstrap';
4 | import { useHistory } from 'react-router-dom';
5 | import { useContext, useState } from 'react';
6 | import UserBadge from './UserBadge';
7 | import LoginButton from './LoginButton';
8 | import UploadModal from './UploadModal';
9 | import { CloudUpload, LightbulbFill, Moon } from 'react-bootstrap-icons';
10 | import md5 from 'md5';
11 | import '../common/Glow.css'
12 | import './NavButton.css'
13 | import ReactPlaceholder from 'react-placeholder/lib';
14 | import { ThemeContext } from 'styled-components';
15 | import { useMediaQuery } from 'react-responsive'
16 | import * as api from '../../utils/api'
17 |
18 |
19 |
20 | const Medium = ({ children }) => useMediaQuery({ maxWidth: 992 - 1 }) ? children : null
21 | const Large = ({ children }) => useMediaQuery({ minWidth: 992 }) ? children : null
22 |
23 | const openInNewTab = (url) => {
24 | const newWindow = window.open(url, '_blank', 'noopener,noreferrer')
25 | if (newWindow) newWindow.opener = null
26 | }
27 |
28 | function NavigationBar({
29 | user,
30 | setAuthX,
31 | setSearchText,
32 | toggleTheme
33 | }) {
34 |
35 | const theme = useContext(ThemeContext)
36 |
37 | const [remoteCollections, setRemoteCollections] = useState([]);
38 | const [uploadModalIsOpen, setUploadModalIsOpen] = useState(false);
39 | const [searchBarInput, setSearchBarInput] = useState('');
40 | const history = useHistory()
41 |
42 | const searchSubmit = (event) => {
43 | event.preventDefault();
44 | history.push(`/all?search=${encodeURIComponent(searchBarInput)}`);
45 | setSearchText(searchBarInput)
46 | return false;
47 | }
48 |
49 | const otpLogin = () => {
50 | const clientId = config.get('CLIENT_ID')
51 | const callback = encodeURIComponent(config.get('OAUTH_CALLBACK'))
52 | const x = md5(Date.now())
53 | setAuthX(x)
54 | console.log(x)
55 | openInNewTab(`https://osu.ppy.sh/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${callback}&state=${x}`)
56 | history.push('/login/enterOtp')
57 | }
58 |
59 | const getRemoteCollections = async () => {
60 | const collections = await api.getUserUploads(user.id)
61 | setRemoteCollections(collections)
62 | }
63 |
64 | const loginButton = process.env.NODE_ENV === 'production' ?
65 |
66 | : Login
67 |
68 | return (
69 |
70 |
71 |
72 |
73 |
74 | osu!Collector
75 |
76 |
77 |
78 |
79 |
80 |
81 | Recent
82 |
83 |
84 |
85 | Popular
86 |
87 |
88 |
89 | Users
90 |
91 |
92 |
93 | Desktop Client
94 |
95 |
96 |
97 |
111 |
112 |
113 |
117 | {theme.darkMode ? : }
118 |
119 |
120 | {
123 | if (user) {
124 | getRemoteCollections()
125 | setUploadModalIsOpen(true)
126 | } else {
127 | alert('Please log in!')
128 | }
129 | }}>
130 |
131 |
132 | Upload
133 |
134 |
135 |
136 |
147 | {user ? : loginButton}
148 |
149 |
150 |
151 |
152 |
153 |
154 | osu!Collector
155 |
156 |
157 |
158 |
159 |
160 | Recent
161 |
162 |
163 |
164 | Popular
165 |
166 |
167 |
168 | Users
169 |
170 |
171 |
172 | Desktop Client
173 |
174 |
175 |
176 | {theme.darkMode ? : }
177 | {theme.darkMode ? 'Light Mode' : 'Dark Mode'}
178 |
179 |
180 | {
181 | if (user)
182 | setUploadModalIsOpen(true)
183 | else
184 | alert('Please log in!')
185 | }}>
186 |
187 | Upload
188 |
189 |
190 | {!user &&
191 | Login
192 | }
193 |
194 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
221 |
222 | )
223 | }
224 |
225 | export default NavigationBar;
--------------------------------------------------------------------------------
/src/components/collection/MapsetCard.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { Badge, Button, Card, CardFooter, Col, Container, Image, ListGroup, ListGroupItem, Row } from '../bootstrap-osu-collector'
3 | import { PlayFill, StopFill } from 'react-bootstrap-icons'
4 | import { bpmToColor, secondsToHHMMSS, useFallbackImg } from '../../utils/misc'
5 | import './MapsetCard.css'
6 | import slimcoverfallback from './slimcoverfallback.jpg'
7 | import DifficultyBadge from '../common/DifficultyBadge'
8 | import Truncate from 'react-truncate'
9 | import osuPng from '../common/mode-osu.png'
10 | import taikoPng from '../common/mode-taiko.png'
11 | import maniaPng from '../common/mode-mania.png'
12 | import catchPng from '../common/mode-catch.png'
13 | import { useMediaQuery } from 'react-responsive'
14 |
15 | const compactThreshold = 991
16 | const Compact = ({ children }) => useMediaQuery({ maxWidth: compactThreshold }) ? children : null
17 | const Full = ({ children }) => useMediaQuery({ minWidth: compactThreshold + 1 }) ? children : null
18 |
19 | function MapsetCard({ beatmapset, beatmaps, className, playing, onPlayClick, onAudioEnd }) {
20 |
21 | const modeToPng = mode => {
22 | return {
23 | 'osu': osuPng,
24 | 'taiko': taikoPng,
25 | 'mania': maniaPng,
26 | 'fruits': catchPng,
27 | }[mode]
28 | }
29 |
30 | const [audio, setAudio] = useState(null);
31 |
32 | useEffect(() => {
33 | const _audio = new Audio(`https://b.ppy.sh/preview/${beatmapset.id}.mp3`)
34 | _audio.volume = 0.2
35 | _audio.addEventListener('ended', onAudioEnd)
36 | setAudio(_audio)
37 | }, [])
38 |
39 | useEffect(() => {
40 | if (!audio) return
41 | if (playing) {
42 | audio.play()
43 | } else {
44 | audio.pause()
45 | audio.currentTime = 0
46 | }
47 | }, [playing])
48 |
49 | return (
50 |
51 |
52 | {/* beatmapset */}
53 |
54 | useFallbackImg(ev, slimcoverfallback)}
58 | style={{ objectFit: 'cover', width: '100%', height: 64 }}
59 | />
60 |
61 |
62 |
63 |
64 |
65 | {beatmapset.title}
66 |
67 |
68 |
69 |
70 | {beatmapset.artist}
71 |
72 |
73 |
74 |
75 |
76 | {playing ? : }
77 |
78 |
79 |
80 |
81 |
82 | Mapped by {beatmapset.creator}
83 |
84 | {/* diffs */}
85 |
86 | {beatmaps.map(beatmap =>
87 |
88 |
89 |
90 | {secondsToHHMMSS(beatmap.hit_length)}
91 |
92 |
102 | {Math.floor(beatmap.bpm)} bpm
103 |
104 |
105 | {beatmap.mode !== 'osu' &&
106 |
112 | }
113 |
114 |
115 | {beatmap.version}
116 |
117 |
118 |
124 | Website
125 |
126 |
132 | Direct
133 |
134 |
135 |
136 | )}
137 |
138 |
139 |
140 |
141 |
142 | {/* beatmapset */}
143 |
144 | useFallbackImg(ev, slimcoverfallback)}
148 | style={{ objectFit: 'cover', width: '100%', height: 64 }}
149 | />
150 |
151 |
152 |
153 |
154 |
155 | {beatmapset.title}
156 |
157 |
158 |
159 |
160 | {beatmapset.artist}
161 |
162 |
163 |
164 |
165 |
166 | {playing ? : }
167 |
168 |
169 |
170 |
171 |
172 | Mapped by {beatmapset.creator}
173 |
174 |
175 | {/* diffs */}
176 |
177 |
178 |
179 | {beatmaps.map(beatmap =>
180 |
181 |
182 |
183 | {secondsToHHMMSS(beatmap.hit_length)}
184 |
185 |
195 | {Math.floor(beatmap.bpm)} bpm
196 |
197 |
198 | {beatmap.mode !== 'osu' &&
199 |
205 | }
206 |
207 | {beatmap.version}
208 |
209 |
215 | Website
216 |
217 |
223 | Direct
224 |
225 |
226 |
227 | )}
228 |
229 |
230 |
231 |
232 |
233 |
234 | )
235 | }
236 |
237 | export default MapsetCard
238 |
--------------------------------------------------------------------------------
/src/utils/api.js:
--------------------------------------------------------------------------------
1 | import config from '../config/config'
2 | import axios from 'axios'
3 |
4 | const getRequestWithQueryParameters = async (route, params = undefined, cancelCallback = undefined) => {
5 | const res = await axios({
6 | method: 'GET',
7 | url: config.get('API_HOST') + route,
8 | params: params,
9 | cancelToken: cancelCallback ? new axios.CancelToken(cancelCallback) : undefined
10 | })
11 | if (res.status !== 200) {
12 | throw new Error(`${route} responded with ${res.status}: ${res.data}`)
13 | }
14 | return res.data
15 | }
16 |
17 | //
18 | async function getRecentCollections(cursor = undefined, perPage = undefined, cancelCallback = undefined) {
19 | return getRequestWithQueryParameters('/api/collections/recent', {
20 | cursor,
21 | perPage
22 | }, cancelCallback)
23 | }
24 |
25 | // range: 'today' or 'week' or 'month' or 'year' or 'alltime'
26 | // Returns PaginatedCollectionData object: https://osucollector.com/docs.html#responses-getCollections-200-schema
27 | async function getPopularCollections(range = 'today', cursor = undefined, perPage = undefined, cancelCallback = undefined) {
28 | return getRequestWithQueryParameters('/api/collections/popularv2', {
29 | range,
30 | cursor,
31 | perPage
32 | }, cancelCallback)
33 | }
34 |
35 | // Returns PaginatedCollectionData object: https://osucollector.com/docs.html#responses-getCollections-200-schema
36 | async function searchCollections(queryString, cursor, perPage = undefined, sortBy = undefined, orderBy = undefined, cancelCallback = undefined) {
37 | return getRequestWithQueryParameters('/api/collections/search', {
38 | search: queryString,
39 | cursor,
40 | perPage,
41 | sortBy,
42 | orderBy
43 | }, cancelCallback)
44 | }
45 |
46 | // Returns CollectionData object: https://osucollector.com/docs.html#responses-getCollectionById-200-schema
47 | async function getCollection(id, cancelCallback = undefined) {
48 | return getRequestWithQueryParameters(`/api/collections/${id}`, {}, cancelCallback)
49 | }
50 |
51 | // Returns PaginatedCollectionData object: https://osucollector.com/docs.html#responses-getCollectionBeatmaps-200-schema
52 | async function getCollectionBeatmaps(id, cursor = undefined, perPage = undefined, sortBy = undefined, orderBy = undefined, filterMin = undefined, filterMax = undefined, cancelCallback = undefined) {
53 | return getRequestWithQueryParameters(`/api/collections/${id}/beatmapsv2`, {
54 | cursor,
55 | perPage,
56 | sortBy,
57 | orderBy,
58 | filterMin: filterMin && ['difficulty_rating', 'bpm'].includes(sortBy) ? filterMin : undefined,
59 | filterMax: filterMax && ['difficulty_rating', 'bpm'].includes(sortBy) ? filterMax : undefined
60 | }, cancelCallback)
61 | }
62 |
63 | // throws error on upload failure
64 | async function uploadCollections(collections) {
65 | const response = await fetch(`${config.get('API_HOST')}/api/collections/upload`, {
66 | method: 'POST',
67 | headers: {
68 | 'Content-Type': 'application/json'
69 | },
70 | body: JSON.stringify(collections)
71 | });
72 | if (response.status === 200)
73 | return response.json();
74 | else
75 | throw new Error(`/api/collections/upload responded with ${response.status}: ${await response.text()}`)
76 | }
77 |
78 | // Returns true on success
79 | async function favouriteCollection(collectionId) {
80 | const response = await fetch(`${config.get('API_HOST')}/api/collections/${collectionId}/favourite`, {
81 | method: 'POST'
82 | })
83 | if (response.status === 200) {
84 | console.log(`collection ${collectionId} added to favourites`)
85 | return true
86 | } else {
87 | console.log(response)
88 | console.log(await response.text())
89 | return false
90 | }
91 | }
92 |
93 | // Returns true on success
94 | async function unfavouriteCollection(collectionId) {
95 | const response = await fetch(`${config.get('API_HOST')}/api/collections/${collectionId}/favourite`, {
96 | "method": "DELETE"
97 | })
98 | if (response.status === 200) {
99 | console.log(`collection ${collectionId} removed from favourites`)
100 | return true
101 | } else {
102 | console.log(response)
103 | console.log(await response.text())
104 | return false
105 | }
106 | }
107 |
108 | async function editCollectionDescription(collectionId, description) {
109 | const response = await fetch(`${config.get('API_HOST')}/api/collections/${collectionId}/description`, {
110 | method: 'PUT',
111 | headers: {
112 | 'Content-Type': 'application/json'
113 | },
114 | body: JSON.stringify({
115 | description: description
116 | })
117 | })
118 | if (response.status === 200) {
119 | console.log('description successfully edited')
120 | return true
121 | } else {
122 | console.log(response)
123 | console.log(await response.text())
124 | return false
125 | }
126 | }
127 |
128 | async function deleteCollection(collectionId) {
129 | const response = await fetch(`${config.get('API_HOST')}/api/collections/${collectionId}`, {
130 | method: 'DELETE'
131 | })
132 | if (response.status === 200) {
133 | console.log('description successfully edited')
134 | return true
135 | } else {
136 | console.log(response)
137 | console.log(await response.text())
138 | return false
139 | }
140 | }
141 |
142 | // Returns PaginatedUserData object: TODO: add link to docs
143 | async function getUsers(page, perPage = undefined, cancelCallback = undefined) {
144 | return getRequestWithQueryParameters('/api/users', {
145 | page,
146 | perPage
147 | }, cancelCallback)
148 | }
149 |
150 | // Returns User object or null if 404: TODO: add link to docs
151 | async function getUser(userId) {
152 | try {
153 | const res = await fetch(`${config.get('API_HOST')}/api/users/${userId}`)
154 | const user = await res.json()
155 | return user
156 | } catch {
157 | return null
158 | }
159 | }
160 |
161 | // https://osucollector.com/docs.html#responses-getOwnUser-200-schema
162 | // (schema might not show in above link, if that's the case open openapi.yaml in swagger editor)
163 | // https://osucollector.com/openapi.yaml
164 | // https://editor.swagger.io/
165 | async function getOwnUser() {
166 | const res = await fetch(`${config.get('API_HOST')}/api/users/me`)
167 | const data = await res.json()
168 | return data.loggedIn ? data.user : null
169 | }
170 |
171 | // Returns an array of CollectionData objects: https://osucollector.com/docs.html#responses-getUserFavourites-200-schema
172 | async function getUserFavourites(userId, cancelCallback = undefined) {
173 | return getRequestWithQueryParameters(`/api/users/${userId}/favourites`, {}, cancelCallback)
174 | }
175 |
176 | // Returns an array of CollectionData objects: TODO: add link to docs
177 | async function getUserUploads(userId, cancelCallback = undefined) {
178 | return getRequestWithQueryParameters(`/api/users/${userId}/uploads`, {}, cancelCallback)
179 | }
180 |
181 | async function getMetadata(cancelCallback = undefined) {
182 | return getRequestWithQueryParameters(`/api/metadata`, {}, cancelCallback)
183 | }
184 |
185 | async function submitOtp(otp, y) {
186 | return await fetch(`${config.get('API_HOST')}/api/authentication/otp?otp=${otp}&y=${y}`, {
187 | method: 'POST',
188 | })
189 | }
190 |
191 | async function linkPaypalSubscription(subscriptionId) {
192 | const endpoint = '/api/payments/paypalSubscription/link'
193 | if (!subscriptionId) {
194 | throw new Error('subscriptionId is required')
195 | }
196 | try {
197 | const response = await axios.post(
198 | config.get('API_HOST') + endpoint,
199 | { subscriptionId: subscriptionId }
200 | )
201 | return response.data
202 | } catch (err) {
203 | throw new Error(`${endpoint} responded with ${err.response.status}: ${err.response.data}`)
204 | }
205 | }
206 |
207 | async function getPaypalSubscription(cancelCallback = undefined) {
208 | const endpoint = '/api/payments/paypalSubscription'
209 | try {
210 | const response = await axios.get(config.get('API_HOST') + endpoint, {
211 | cancelToken: cancelCallback ? new axios.CancelToken(cancelCallback) : undefined
212 | })
213 | return response.data
214 | } catch (err) {
215 | if (err.response.status === 404) {
216 | return null
217 | } else {
218 | console.log(`${endpoint} responded with ${err.response.status}: ${err.response.data}`)
219 | return null
220 | }
221 | }
222 | }
223 |
224 | async function cancelPaypalSubscription() {
225 | const endpoint = '/api/payments/paypalSubscription/cancel'
226 | try {
227 | await axios.post(config.get('API_HOST') + endpoint)
228 | } catch (err) {
229 | if (err.response.status !== 404) {
230 | throw new Error(`${endpoint} responded with ${err.response.status}: ${JSON.stringify(err.response.data)}`)
231 | }
232 | }
233 | }
234 |
235 | async function createCustomer(email) {
236 | const response = await fetch(`${config.get('API_HOST')}/api/payments/createCustomer`, {
237 | method: 'POST',
238 | headers: {
239 | 'Content-Type': 'application/json'
240 | },
241 | body: JSON.stringify({
242 | email: email
243 | })
244 | });
245 | if (response.status === 200)
246 | return await response.text()
247 | else
248 | throw new Error(`/api/payments/createCustomer responded with ${response.status}: ${await response.text()}`)
249 | }
250 |
251 | async function createSubscription() {
252 | const response = await fetch(`${config.get('API_HOST')}/api/payments/createSubscription`, {
253 | method: 'POST'
254 | })
255 | if (response.status === 200)
256 | return response.json()
257 | else
258 | throw new Error(`/api/payments/createSubscription responded with ${response.status}: ${await response.text()}`)
259 | }
260 |
261 | async function getSubscription(cancelCallback = undefined) {
262 | try {
263 | const response = await axios.get(`${config.get('API_HOST')}/api/payments/stripeSubscription`, {
264 | cancelToken: cancelCallback ? new axios.CancelToken(cancelCallback) : undefined
265 | })
266 | return response.data
267 | } catch (err) {
268 | if (err.response.status === 404) {
269 | return null
270 | } else {
271 | console.log(`/api/payments/createSubscription responded with ${err.response.status}: ${err.response.data}`)
272 | return null
273 | }
274 | }
275 | }
276 |
277 | async function cancelSubscription() {
278 | const endpoint = '/api/payments/cancelSubscription'
279 | try {
280 | const response = await axios.post(config.get('API_HOST') + endpoint)
281 | return response.data
282 | } catch (err) {
283 | throw new Error(`${endpoint} responded with ${err.response.status}: ${err.response.data}`)
284 | }
285 | }
286 |
287 | async function unlinkTwitchAccount() {
288 | const response = await fetch(`${config.get('API_HOST')}/api/users/me/unlinkTwitch`, {
289 | method: 'POST'
290 | })
291 | if (response.status === 200)
292 | return await response.text()
293 | else
294 | throw new Error(`/api/users/me/unlinkTwitch responded with ${response.status}: ${await response.text()}`)
295 | }
296 |
297 | async function getInstallerURL() {
298 | const response = await axios.get('/api/installerURL')
299 | return response.data
300 | }
301 |
302 | async function postComment(collectionId, message) {
303 | const response = await fetch(`${config.get('API_HOST')}/api/collections/${collectionId}/comments`, {
304 | method: 'POST',
305 | headers: {
306 | 'Content-Type': 'application/json'
307 | },
308 | body: JSON.stringify({
309 | message: message
310 | })
311 | })
312 | if (response.status === 200)
313 | return await response.text()
314 | else
315 | throw new Error(`POST /api/collections/${collectionId}/comments responded with ${response.status}: ${await response.text()}`)
316 | }
317 |
318 | async function likeComment(collectionId, commentId, remove = false) {
319 | const response = await fetch(`${config.get('API_HOST')}/api/collections/${collectionId}/comments/${commentId}/like`, {
320 | method: 'POST',
321 | headers: {
322 | 'Content-Type': 'application/json'
323 | },
324 | body: JSON.stringify({
325 | remove: remove
326 | })
327 | })
328 | if (response.status === 200)
329 | return await response.text()
330 | else
331 | throw new Error(`POST /api/collections/${collectionId}/comments/${commentId}/like responded with ${response.status}: ${await response.text()}`)
332 | }
333 |
334 | async function deleteComment(collectionId, commentId) {
335 | const response = await fetch(`${config.get('API_HOST')}/api/collections/${collectionId}/comments/${commentId}`, {
336 | method: 'DELETE'
337 | })
338 | if (response.status === 200)
339 | return await response.text()
340 | else
341 | throw new Error(`DELETE /api/collections/${collectionId}/comments/${commentId} responded with ${response.status}: ${await response.text()}`)
342 | }
343 |
344 | async function reportComment(collectionId, commentId) {
345 | const response = await fetch(`${config.get('API_HOST')}/api/collections/${collectionId}/comments/${commentId}/report`, {
346 | method: 'POST'
347 | })
348 | if (response.status === 200)
349 | return await response.text()
350 | else
351 | throw new Error(`POST /api/collections/${collectionId}/comments/${commentId}/report responded with ${response.status}: ${await response.text()}`)
352 | }
353 |
354 | export {
355 | getRecentCollections,
356 | getPopularCollections,
357 | searchCollections,
358 | getCollection,
359 | getCollectionBeatmaps,
360 | uploadCollections,
361 | favouriteCollection,
362 | unfavouriteCollection,
363 | editCollectionDescription,
364 | deleteCollection,
365 | getUsers,
366 | getUser,
367 | getOwnUser,
368 | getUserFavourites,
369 | getUserUploads,
370 | getMetadata,
371 | submitOtp,
372 | linkPaypalSubscription,
373 | getPaypalSubscription,
374 | cancelPaypalSubscription,
375 | createCustomer,
376 | createSubscription,
377 | getSubscription,
378 | cancelSubscription,
379 | unlinkTwitchAccount,
380 | getInstallerURL,
381 | postComment,
382 | likeComment,
383 | deleteComment,
384 | reportComment
385 | }
--------------------------------------------------------------------------------
/src/components/collection/Comments.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Button, Card, CardFooter, Modal } from '../bootstrap-osu-collector';
3 | import Dropdown from 'react-bootstrap/Dropdown'
4 | import DropdownButton from 'react-bootstrap/DropdownButton'
5 | import { ChatFill, FlagFill, HandThumbsUpFill } from 'react-bootstrap-icons'
6 | import styled, { css } from 'styled-components'
7 | import { Image } from 'react-bootstrap';
8 | import moment from 'moment'
9 | import * as api from '../../utils/api'
10 | import './Comments.css'
11 |
12 | const ClickableCard = styled(Card)`
13 | cursor: pointer;
14 | &:hover {
15 | ${props => props.theme.darkMode ? css`
16 | background-color: ${props => props.theme.primary25}
17 | ` : css`
18 | background-color: #eee
19 | `}
20 | }
21 | `
22 |
23 | const ClickableCardFooter = styled(CardFooter)`
24 | cursor: pointer;
25 | &:hover {
26 | ${props => props.theme.darkMode ? css`
27 | background-color: ${props => props.theme.primary25}
28 | ` : css`
29 | background-color: #eee
30 | `}
31 | }
32 | `
33 |
34 | const sortByLikes = (a, b) => (b.upvotes?.length || 0) - (a.upvotes?.length || 0)
35 | const sortByDate = (a, b) => b.date._seconds - a.date._seconds
36 |
37 | function Comment({ collectionId, comment, user }) {
38 | const [localLikeOffset, setlocalLikeOffset] = useState(undefined)
39 | const [locallyDeleted, setLocallyDeleted] = useState(false)
40 | const [reported, setReported] = useState(false)
41 |
42 | if (!comment) {
43 | return
44 | }
45 |
46 | const relativeDate = moment.unix(comment.date._seconds).fromNow()
47 |
48 | const likeComment = (commentId) => {
49 | if (!user) {
50 | alert('You must be logged in to like comments')
51 | return
52 | }
53 | const remove = localLikeOffset === undefined ?
54 | comment.upvotes.includes(user.id) :
55 | (localLikeOffset === -0.5) || (localLikeOffset === 1) // wtf
56 |
57 | // update locally
58 | const newComment = { ...comment }
59 | const upvotes = new Set(newComment.upvotes)
60 | if (remove) {
61 | upvotes.delete(user.id)
62 | } else {
63 | upvotes.add(user.id)
64 | }
65 | newComment.upvotes = Array.from(upvotes)
66 | if (localLikeOffset === undefined) {
67 | setlocalLikeOffset(comment.upvotes.includes(user.id) ? -1 : 1)
68 | } else {
69 | // wtf
70 | setlocalLikeOffset(Math.abs(localLikeOffset) > 0.75 ? localLikeOffset / 2 : localLikeOffset * 2)
71 | }
72 |
73 | // update on server
74 | try {
75 | api.likeComment(collectionId, commentId, remove)
76 | } catch (err) {
77 | console.log(err)
78 | }
79 | }
80 |
81 | // delete comment
82 | const deleteComment = async () => {
83 | setLocallyDeleted(true)
84 | try {
85 | await api.deleteComment(collectionId, comment.id)
86 | } catch (err) {
87 | console.log(err)
88 | }
89 | }
90 |
91 | // post comment
92 | const reportComment = async () => {
93 | setReported(true)
94 | try {
95 | await api.reportComment(collectionId, comment.id)
96 | } catch (err) {
97 | console.log(err)
98 | }
99 | }
100 |
101 | if (locallyDeleted) {
102 | return comment deleted
103 | }
104 | return
105 | {/* commenter avatar */}
106 |
107 |
108 | {/* username & date */}
109 |
110 |
111 | {comment.username} - {relativeDate}
112 | {process.env.NODE_ENV === 'development' && - {comment.id} }
113 |
114 | {/* report or delete */}
115 |
116 | {comment.userId === user?.id ?
117 |
122 | delete
123 |
124 | :
125 | (reported ?
126 |
129 | reported!
130 |
131 | :
132 |
137 |
138 |
139 | )
140 | }
141 |
142 |
143 | {/* comment text */}
144 |
{comment.message}
145 | {/* likes */}
146 |
147 | likeComment(comment.id)}
150 | style={{ cursor: 'pointer' }}
151 | >
152 | {localLikeOffset === undefined ?
153 | <>
154 | {comment.upvotes.includes(user?.id) ?
155 |
156 | :
157 |
158 | }
159 | {comment.upvotes?.length || 0}
160 | >
161 | :
162 | <>
163 | {(localLikeOffset === -0.5) || (localLikeOffset === 1) ? // wtf
164 |
165 | :
166 |
167 | }
168 | {(comment.upvotes?.length || 0) + (Math.abs(localLikeOffset) < 1 ? 0 : localLikeOffset) /* wtf */}
169 | >
170 | }
171 |
172 |
173 |
174 |
175 | }
176 |
177 | function Comments({ collectionId, comments, user, refreshCollection }) {
178 |
179 | const [commentsModalIsOpen, setCommentsModalIsOpen] = useState(false)
180 | const [sortCommentsBy, setSortCommentsBy] = useState('likes')
181 | const [unsavedComment, setUnsavedComment] = useState('')
182 | const [showSuccessModal, setShowSuccessModal] = useState(false)
183 | const [posting, setPosting] = useState(false)
184 |
185 | const postComment = async () => {
186 | if (posting) {
187 | return
188 | }
189 | if (!user) {
190 | alert('You must be logged in to comment')
191 | return
192 | }
193 | // check for duplicates
194 | const duplicateFound = comments?.find(comment => comment.userId === user.id && comment.message.trim() === unsavedComment.trim())
195 | if (duplicateFound) {
196 | alert('don\'t send the same comment twice')
197 | return
198 | }
199 |
200 | setPosting(true)
201 | try {
202 | await api.postComment(collectionId, unsavedComment)
203 | setPosting(false)
204 | } catch (err) {
205 | alert(err)
206 | setPosting(false)
207 | }
208 | setCommentsModalIsOpen(false)
209 | setShowSuccessModal(true)
210 | refreshCollection()
211 | }
212 |
213 | const hideModal = () => {
214 | setCommentsModalIsOpen(false)
215 | refreshCollection()
216 | }
217 |
218 | return (
219 | <>
220 | {comments?.length > 0 ?
221 |
222 |
223 | {/* top rated comment */}
224 |
229 |
230 | setCommentsModalIsOpen(true)}>
231 | View all {comments.length} comment(s)
232 |
233 |
234 | :
235 | {
238 | if (!user && (!comments || comments.length === 0)) {
239 | alert('You must be logged in to comment');
240 | return;
241 | }
242 | setCommentsModalIsOpen(true)
243 | }}
244 | >
245 |
246 |
247 |
248 |
249 |
No comments. Leave a comment!
250 |
251 |
252 | }
253 |
254 | {/* full comments view */}
255 |
261 |
262 | {/* comments */}
263 | {comments?.length > 0 &&
264 | <>
265 |
266 |
267 |
{comments?.length} Comment{comments?.length > 1 && 's'}
268 |
274 | setSortCommentsBy('likes')}
277 | >
278 | Likes
279 |
280 | setSortCommentsBy('date')}
283 | >
284 | Recent
285 |
286 |
287 |
288 | {comments.sort(sortCommentsBy === 'likes' ? sortByLikes : sortByDate).map((comment, index) => {
289 | const last = index === comments.length - 1
290 | return
291 |
296 | {!last &&
}
297 |
298 | })}
299 |
300 |
301 | >
302 | }
303 | {/* post comment */}
304 |
305 |
Leave a comment
306 |
307 |
308 | {user?.id && }
309 |
310 |
334 |
335 |
336 |
337 |
338 | setShowSuccessModal(false)}
341 | size='sm'
342 | centered
343 | >
344 |
345 | Comment posted!
346 |
347 |
348 | >
349 | )
350 | }
351 |
352 | export default Comments
353 |
--------------------------------------------------------------------------------
/src/components/client/SubscriptionDetailsModal.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import { Button, Modal, ModalBody } from '../bootstrap-osu-collector'
3 | import { useState } from 'react'
4 | import * as api from '../../utils/api'
5 | import Tabs from 'react-bootstrap/Tabs'
6 | import { Spinner, Tab } from 'react-bootstrap'
7 | import ReactJson from 'react-json-view'
8 | import moment from 'moment'
9 | import { ExclamationTriangleFill } from 'react-bootstrap-icons'
10 |
11 | function SubscriptionDetailsModal({
12 | user,
13 | show,
14 | onHide,
15 | paypalSubscription,
16 | stripeSubscription,
17 | onPaypalSubscriptionCancel,
18 | onStripeSubscriptionCancel
19 | }) {
20 | // auto renew issue
21 | const [autorenewNoticeVisible, setAutorenewNoticeVisible] = useState(false)
22 |
23 | const showPaypalSubscription = (paypalSubscription, stripeSubscription) => {
24 | if (!paypalSubscription) {
25 | return false
26 | }
27 | if (paypalSubscription.status.toLowerCase() === 'active') {
28 | return true
29 | }
30 | // status: cancelled or expired or other
31 | if (!stripeSubscription) {
32 | return true
33 | } else if (stripeSubscription.status === 'active') {
34 | return false
35 | } else {
36 | // status: (cancelled or expired or other) for BOTH subscriptions
37 | // show the one that is more recent
38 | const paypalSubscriptionDateCreated = new Date(paypalSubscription.create_time)
39 | const stripeSubscriptionDateCreated = new Date(stripeSubscription.created * 1000)
40 | return paypalSubscriptionDateCreated > stripeSubscriptionDateCreated
41 | }
42 | }
43 | const showStripeSubscription = (paypalSubscription, stripeSubscription) => {
44 | if (!stripeSubscription) {
45 | return false
46 | }
47 | if (stripeSubscription.status === 'active') {
48 | return true
49 | }
50 | // status: cancelled or expired or other
51 | if (!paypalSubscription) {
52 | return true
53 | } else if (paypalSubscription.status.toLowerCase() === 'active') {
54 | return false
55 | } else {
56 | // status: (cancelled or expired or other) for BOTH subscriptions
57 | // show the one that is more recent
58 | const paypalSubscriptionDateCreated = new Date(paypalSubscription.create_time)
59 | const stripeSubscriptionDateCreated = new Date(stripeSubscription.created * 1000)
60 | return stripeSubscriptionDateCreated > paypalSubscriptionDateCreated
61 | }
62 | }
63 |
64 | const paypalEndDate = new Date(paypalSubscription?.billing_info.next_billing_time || (user?.private?.subscriptionExpiryDate?._seconds * 1000))
65 | const paypalEndDateVerb =
66 | new Date() > paypalEndDate ? 'Ended' :
67 | paypalSubscription?.status.toLowerCase() === 'active' ? 'Renews' :
68 | 'Ends'
69 |
70 | const stripeEndDate = ['canceled', 'past_due', 'incomplete', 'incomplete_expired'].includes(stripeSubscription?.status) ?
71 | new Date(user?.private?.subscriptionExpiryDate?._seconds * 1000)
72 | : new Date(stripeSubscription?.current_period_end * 1000)
73 | const stripeEndDateVerb =
74 | new Date() > stripeEndDate ? 'Ended' :
75 | stripeSubscription?.cancel_at_period_end ? 'Ends' :
76 | stripeSubscription?.status.toLowerCase() === 'active' ? 'Renews' :
77 | 'Ends'
78 |
79 | return (
80 | <>
81 |
87 |
88 | Your subscription
89 | {!user?.private?.paypalSubscriptionId && !user?.private?.stripeSubscriptionId &&
90 | Nothing to show
91 | }
92 |
93 | {/* PayPal Subscription */}
94 | {showPaypalSubscription(paypalSubscription, stripeSubscription) && <>
95 |
103 |
108 | {paypalSubscription.subscriber.email_address}
109 |
110 | }
111 | subscriptionObject={paypalSubscription}
112 | cancelSubscriptionApiCall={api.cancelPaypalSubscription}
113 | canCancelSubscription={paypalSubscription.status.toLowerCase() === 'active'}
114 | onSubscriptionCancel={onPaypalSubscriptionCancel}
115 | />
116 | >}
117 |
118 | {showPaypalSubscription(paypalSubscription, stripeSubscription) && showStripeSubscription(paypalSubscription, stripeSubscription) &&
119 |
120 | }
121 |
122 | {/* Stripe Subscription */}
123 | {showStripeSubscription(paypalSubscription, stripeSubscription) && <>
124 |
137 | {stripeSubscription.default_payment_method.card.brand}
138 | {' '}ending in{' '}
139 | {stripeSubscription.default_payment_method.card.last4}
140 |
141 | :
142 | 'No current payment method'
143 | }
144 | subscriptionObject={stripeSubscription}
145 | cancelSubscriptionApiCall={api.cancelSubscription}
146 | canCancelSubscription={stripeSubscription.status.toLowerCase() !== 'canceled' && !stripeSubscription.cancel_at_period_end}
147 | onSubscriptionCancel={onStripeSubscriptionCancel}
148 | specialStatusIndicator={
149 | stripeSubscription?.status === 'past_due' && (new Date(stripeSubscription.created * 1000)) < new Date('2021-12-13T17:12:10+00:00') &&
150 | <>
151 | setAutorenewNoticeVisible(true)}
155 | />
156 | setAutorenewNoticeVisible(false)} centered>
157 |
158 | We are currently experiencing an issue where subscriptions created before December 7th are not auto-renewing.
159 |
160 | If your subscription is over and has not auto-renewed, you can cancel your current subscription, then create a new subscription.
161 |
162 | We are very sorry for the inconvenience.
163 |
164 |
165 | setAutorenewNoticeVisible(false)}>
166 | Ok
167 |
168 |
169 |
170 | >
171 | }
172 | />
173 | >}
174 |
175 |
176 |
177 |
178 | >
179 | )
180 | }
181 |
182 |
183 | const SubscriptionDetails = ({
184 | subscriptionId,
185 | status,
186 | created,
187 | endDateVerb,
188 | endDate,
189 | paymentMethodComponent,
190 | subscriptionObject,
191 | cancelSubscriptionApiCall,
192 | canCancelSubscription,
193 | onSubscriptionCancel,
194 | specialStatusIndicator
195 | }) => {
196 | const [cancelSubscriptionConfirmationVisible, setCancelSubscriptionConfirmationVisible] = useState(false)
197 | const [cancellingSubscription, setCancellingSubscription] = useState(false)
198 | const cancelSubscription = async () => {
199 | setCancellingSubscription(true)
200 |
201 | try {
202 | await cancelSubscriptionApiCall()
203 | } catch (err) {
204 | alert(err.message)
205 | }
206 |
207 | setCancellingSubscription(false)
208 | setCancelSubscriptionConfirmationVisible(false)
209 | onSubscriptionCancel()
210 | }
211 | const [showJSON, setShowJSON] = useState(false)
212 |
213 | return (
214 |
215 |
216 |
217 |
Subscription number
218 |
{subscriptionId}
219 |
220 |
221 |
Status
222 |
223 | {status}
224 | {specialStatusIndicator}
225 |
226 |
227 |
228 |
229 |
230 |
231 |
Created on
232 |
{moment(created).format('MMMM Do, YYYY')}
233 |
234 |
235 |
{endDateVerb} on
236 |
{endDate ? moment(endDate).format('MMMM Do, YYYY') : '---'}
237 |
238 |
239 |
240 |
241 |
242 |
Subscription fee
243 |
$1.99 USD per month
244 |
245 |
246 |
247 | Payment method
248 |
249 | {paymentMethodComponent}
250 |
251 |
252 |
253 |
254 | setShowJSON(!showJSON)}
259 | >
260 | Show full details
261 |
262 | alert('If you\'d like to change your payment method, please cancel your subscription, then create a new subscription with the new payment method after the old subscription ends.')}
267 | >
268 | Change payment method
269 |
270 | setCancelSubscriptionConfirmationVisible(true)}
276 | >
277 | Cancel subscription
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
setCancelSubscriptionConfirmationVisible(false)} centered>
303 |
304 | Confirmation
305 |
306 | Are you sure you would like to cancel your subscription?
307 |
308 | setCancelSubscriptionConfirmationVisible(false)}>
309 | No
310 |
311 |
312 | {cancellingSubscription ?
313 | <>
314 |
321 | Processing...
322 | >
323 | : 'Yes, cancel my subscription'}
324 |
325 |
326 |
327 |
328 | )
329 | }
330 |
331 | export default SubscriptionDetailsModal
332 |
--------------------------------------------------------------------------------
/src/components/client/DesktopClient.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import Badge from 'react-bootstrap/Badge'
3 | import { LinkContainer } from 'react-router-bootstrap'
4 | import * as api from '../../utils/api'
5 | import { useHistory } from 'react-router'
6 | import { Button, Card, CardBody, Col, Container, Row, Spinner } from '../bootstrap-osu-collector'
7 | import downloadsPng from './downloads.png'
8 | import importPng from './import.png'
9 | import darkmodePng from './darkmode.png'
10 | import styled from 'styled-components'
11 | import { HeartFill } from 'react-bootstrap-icons'
12 | import { PayPalButtons } from "@paypal/react-paypal-js"
13 | import { Alert } from 'react-bootstrap'
14 | import SubscriptionDetailsModal from './SubscriptionDetailsModal'
15 |
16 | const downloadInstaller = async () => {
17 | try {
18 | const installerURL = await api.getInstallerURL()
19 | open(installerURL)
20 | } catch (err) {
21 | alert('Error ' + err.response.status + ': ' + err.response.data)
22 | }
23 | }
24 |
25 | const paidSubscriptionActive = (user, paypalSubscription, stripeSubscription) => {
26 | if (user?.private?.subscriptionExpiryDate) {
27 | const subscriptionExpiryDate = new Date(user.private.subscriptionExpiryDate._seconds * 1000)
28 | if (subscriptionExpiryDate > new Date()) {
29 | return true
30 | }
31 | }
32 | if (paypalSubscription?.status.toLowerCase() === 'active') {
33 | return true
34 | }
35 | if (stripeSubscription?.status.toLowerCase() === 'active') {
36 | return true
37 | }
38 | return false
39 | }
40 |
41 | const offsetX = 3
42 | const offsetY = 3
43 | const ShadowImg = styled.img`
44 | -webkit-filter: drop-shadow(${offsetX}px ${offsetY}px 8px rgba(0,0,0,0.5));
45 | filter: url(#drop-shadow);
46 | -ms-filter: "progid:DXImageTransform.Microsoft.Dropshadow(OffX=${offsetX}, OffY=${offsetY}, Color='#444')";
47 | filter: "progid:DXImageTransform.Microsoft.Dropshadow(OffX=${offsetX}, OffY=${offsetY}, Color='#444')";
48 | `
49 |
50 | const ShadowHeart = styled(HeartFill)`
51 | -webkit-filter: drop-shadow(${offsetX}px ${offsetY}px 8px rgba(0,0,0,0.5));
52 | filter: url(#drop-shadow);
53 | -ms-filter: "progid:DXImageTransform.Microsoft.Dropshadow(OffX=${offsetX}, OffY=${offsetY}, Color='#444')";
54 | filter: "progid:DXImageTransform.Microsoft.Dropshadow(OffX=${offsetX}, OffY=${offsetY}, Color='#444')";
55 | `
56 |
57 | function DesktopClient({ user, setUser }) {
58 | const history = useHistory()
59 |
60 | const [paypalError, setPaypalError] = useState(null)
61 |
62 | const [paypalSubscription, setPaypalSubscription] = useState(null)
63 | const onPaypalSubscriptionCancel = async () => {
64 | setUser(await api.getOwnUser())
65 | if (user?.private?.paypalSubscriptionId) {
66 | setPaypalSubscription(await api.getPaypalSubscription(user?.private?.paypalSubscriptionId))
67 | }
68 | }
69 |
70 | const [stripeSubscription, setStripeSubscription] = useState(null)
71 | const onStripeSubscriptionCancel = async () => {
72 | setUser(await api.getOwnUser())
73 | if (user?.private?.stripeSubscriptionId) {
74 | setStripeSubscription(await api.getSubscription(user?.private?.stripeSubscriptionId))
75 | }
76 | }
77 |
78 | useEffect(() => {
79 | if (!user || paypalSubscription || stripeSubscription) {
80 | return
81 | }
82 | let cancel1, cancel2
83 | if (user?.private?.paypalSubscriptionId) {
84 | api.getPaypalSubscription(c => cancel1 = c).then(setPaypalSubscription).catch(console.log)
85 | }
86 | if (user?.private?.stripeSubscriptionId) {
87 | api.getSubscription(c => cancel2 = c).then(setStripeSubscription).catch(console.log)
88 | }
89 | return () => {
90 | if (cancel1) cancel1()
91 | if (cancel2) cancel2()
92 | }
93 | }, [user])
94 |
95 | const [paymentModalVisible, setPaymentModalVisible] = useState(false)
96 |
97 | // Unlink Twitch
98 | const [unlinkingTwitchAccount, setUnlinkingTwitchAccount] = useState(false)
99 | const unlinkTwitchAccount = async () => {
100 | setUnlinkingTwitchAccount(true)
101 |
102 | try {
103 | await api.unlinkTwitchAccount()
104 | console.log('ok')
105 | } catch (err) {
106 | alert(err.message)
107 | }
108 | const data = await api.getOwnUser()
109 | setUser(data)
110 | console.log(data)
111 |
112 | setUnlinkingTwitchAccount(false)
113 | }
114 |
115 | // Current Status
116 | const linkedTwitchAccountStatus = user?.private?.linkedTwitchAccount?.displayName || 'Not linked'
117 | const twitchSubStatus = user?.isSubbedToFunOrange ? 'Subbed' : 'Not subbed'
118 |
119 | const twitchSub = user?.isSubbedToFunOrange
120 | const paidSub = paidSubscriptionActive(user, paypalSubscription, stripeSubscription)
121 |
122 | console.log(stripeSubscription)
123 |
124 | const Divider = () => (
125 |
132 | )
133 |
134 | return (
135 |
136 | {/* {process.env.NODE_ENV === 'production' &&
137 |
138 | you shouldnt be here unless youre a dev
139 |
140 | } */}
141 |
142 |
143 |
144 |
Support us to gain access to these features!
145 | {/*
146 |
The greatest thing FunOrange has made since osu! trainer
147 |
*/}
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
Download entire collections
159 |
160 | osu!Collector Desktop feature
161 |
162 |
163 | Download all the beatmaps in a collection with one click.
164 |
165 | Downloads are hosted on our own servers.
166 | No rate limits, stupid fast download speeds.
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
Import collections
182 |
183 | osu!Collector Desktop feature
184 |
185 |
Directly add collections to osu! with the click of a button
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
Dark mode
199 |
Also available on the website
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
Help pay for server costs
213 |
214 | I had to find some way to monetize this project so that it could continue running on its own.
215 | I figured something in similar vein to osu! supporter would be the best approach.
216 | Any support you give us is greatly appreciated!
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 | Download osu!Collector Desktop
231 |
232 |
233 |
234 |
235 |
241 | Windows (64-bit)
242 |
243 |
244 | {user?.paidFeaturesAccess ? 'Thank you for supporting us! You are awesome.' : 'Please support us to gain access to the desktop client.'}
245 |
246 |
247 |
248 |
249 | Two ways to support us!
250 |
251 | Please note that supporting us with both methods at the same time will not extend your supporter status!
252 | If you want to save money we recommend doing only 1 option.
253 |
254 |
255 |
256 |
257 |
258 |
259 | Option 1 free with Twitch Prime
260 |
261 |
262 | 1 Link your Twitch account with osu!Collector
263 | {!user ?
264 |
267 | You are not logged in
268 |
269 | : user?.private?.linkedTwitchAccount ?
270 |
273 | Already linked: {user.private.linkedTwitchAccount.displayName}
274 |
275 | :
276 |
279 | Link Twitch Account
280 |
281 | }
282 |
283 |
284 | 2 Subscribe to FunOrange's Twitch channel (if you haven't already)
285 |
288 | FunOrange's Twitch Channel
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 | Option 2
299 |
300 |
301 | {!user ?
302 | You are not logged in
303 | : paidSub ?
304 | You already have a paid subscription
305 | : twitchSub &&
306 | You are already subbed to FunOrange's Twitch channel!
307 | }
308 | {paypalError &&
309 |
310 |
311 | {paypalError.message}
312 |
313 |
314 | }
315 |
316 | Purchase the desktop client for $1.99 per month
317 | {
327 | return actions.subscription.create({
328 | plan_id: 'P-5DC05698WC351562JMGZFV6Y' // production: $1.99 per month
329 | // plan_id: 'P-1YN01180390590643MGZNV3Y' // test: $0.05 per day
330 | })
331 | }}
332 | // eslint-disable-next-line no-unused-vars
333 | onApprove={async (data, actions) => {
334 | await api.linkPaypalSubscription(data.subscriptionID)
335 | history.push('/payments/success')
336 | }}
337 | onError={error => {
338 | console.error(error)
339 | setPaypalError(error)
340 | }}
341 | />
342 |
343 |
344 | Or pay with card
345 |
346 |
347 |
348 |
352 |
353 | Pay with credit card
354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 | Current status
369 |
370 |
371 |
372 | You are {!user?.paidFeaturesAccess && 'not'} currently supporting osu!Collector.
373 |
374 |
375 |
376 |
377 |
378 | Twitch account
379 |
380 |
381 |
385 | {linkedTwitchAccountStatus}
386 |
387 |
388 | {user?.private?.linkedTwitchAccount?.displayName &&
389 |
390 | {unlinkingTwitchAccount ?
391 |
392 | :
393 | Unlink
394 | }
395 |
396 | }
397 |
398 |
399 |
400 | Twitch Sub
401 |
402 |
403 |
407 | {twitchSubStatus}
408 |
409 |
410 |
411 | {user?.private?.error &&
412 |
413 | An error occurred. Please try to unlink and relink your twitch account.
414 |
415 | }
416 |
417 |
418 |
419 |
420 |
421 |
422 | Paid Subscription
423 |
424 |
428 | {paidSubscriptionActive(user, paypalSubscription, stripeSubscription) ? 'Active' : 'Inactive'}
429 |
430 |
431 | {(user?.private?.paypalSubscriptionId || user?.private?.stripeSubscriptionId) &&
432 | setPaymentModalVisible(true)} size='sm' variant='outline-secondary'>Show details
433 | }
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 |
450 |
451 |
452 |
453 |
454 |
455 |
456 | setPaymentModalVisible(false)}
461 | paypalSubscription={paypalSubscription}
462 | stripeSubscription={stripeSubscription}
463 | onPaypalSubscriptionCancel={onPaypalSubscriptionCancel}
464 | onStripeSubscriptionCancel={onStripeSubscriptionCancel}
465 | />
466 |
467 |
468 |
469 | )
470 | }
471 |
472 | export default DesktopClient
--------------------------------------------------------------------------------