some stuff
Seto Elkahfi committed
Aug 2, 2024 at 13:58 UTC
e78b5e48686b8bffe5ff0ff9e5f02558892bb427
23 files changed
+662
-28
frontend/splitfire-desktop/app/_src/lib/tauriHandler.ts
+1
@@ -21,4 +21,5 @@ export const TAURI_ACCOUNT_REGISTER = 'account_register'
21
export const TAURI_CONTENT_CAROUSEL = 'content_carousel'
22
export const TAURI_CONTENT_READY_TO_PLAY = 'content_ready_to_play'
23
export const TAURI_CONTENT_SONG_BRIDGE_DETAIL = 'content_song_bridge_detail'
24
+export const TAURI_CONTENT_TOP_VOTED = 'content_top_voted'
25
frontend/splitfire-desktop/app/_ui/menus.ts
+4
@@ -15,6 +15,10 @@ export const menus: { items: Item[] }[] = [
15
name: 'Ready to Play!',
16
slug: 'ready-to-play',
17
},
18
+ {
19
+ name: 'Top voted',
20
+ slug: 'top-voted',
21
+ },
22
],
23
},
24
];
frontend/splitfire-desktop/app/play/page.tsx
+8
-2
@@ -3,7 +3,7 @@
3
import { invoke } from "@tauri-apps/api";
4
import { useContext, useEffect, useState } from "react";
5
import { TAURI_PLAYER_PREPARE } from "../_src/lib/tauriHandler";
6
-import { useSearchParams } from "next/navigation";
6
+import { useRouter, useSearchParams } from "next/navigation";
7
import { useLogger } from "../_src/lib/logger";
8
import { IconSpinner } from "../_ui/components/icons";
9
import { PlayerPrepareResponse } from "@/models/content";
@@ -25,6 +25,7 @@ export default function Page() {
25
const [state, setState] = useState(State.LOADING);
26
const user = useContext(UserContext);
27
const userId = user.user?.user.id;
28
+ const router = useRouter();
29
30
useEffect(() => {
31
log.debug("Play page loaded.");
@@ -55,7 +56,12 @@ export default function Page() {
56
}, []);
57
58
// Sanity check
58
- if (!audioFileId || !userId) {
59
+ if (!userId) {
60
+ log.error("No user context");
61
+ return router.push("/login");
62
+ }
63
+
64
+ if (!audioFileId) {
65
log.error("Missing audio file ID or user ID");
66
return <div>Missing audio file ID</div>;
67
}
frontend/splitfire-desktop/app/profile/layout.tsx
-5
@@ -7,11 +7,6 @@ export default async function Layout({
7
}) {
8
return (
9
<div className="space-y-9">
10
- <div className="flex justify-between">
11
- <div className="self-start">
12
- <h1 className="text-3xl font-bold">Profile</h1>
13
- </div>
14
- </div>
10
<div>{children}</div>
11
</div>
12
);
frontend/splitfire-desktop/app/profile/page.tsx
+11
-2
@@ -5,9 +5,13 @@ import { UserContext } from "../_src/lib/CurrentUserContext";
5
import { useLogger } from "../_src/lib/logger";
6
import { SkeletonCard } from "../_ui/skeleton-card";
7
import { usernameOrId } from "../_src/models/user";
8
+import { useSearchParams } from "next/navigation";
9
+import Image from "next/image";
10
11
export default function Page() {
12
const log = useLogger("Profile/Page");
13
+ const searchParams = useSearchParams();
14
+ const userId = searchParams.get("userId");
15
const currentUser = useContext(UserContext);
16
log.debug("currentUser");
17
@@ -20,14 +24,19 @@ export default function Page() {
24
25
return (
26
<div className="prose prose-sm prose-invert max-w-none">
27
+ <div className="flex justify-between">
28
+ <div className="self-start">
29
+ <h1 className="text-3xl font-bold">{user.name}</h1>
30
+ </div>
31
+ </div>
32
<div className="grid grid-rows-2 grid-flow-row auto-rows-max">
33
<div className="max-h-1">
34
+ <Image src={user.gravatar_url} alt="avatar" width={50} height={50} className="rounded-full"/>
35
<h2>@{usernameOrId(user)}</h2>
26
- <p>{user.name}</p>
36
</div>
37
<div className="max-w-none">
38
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
30
- <h2 className="text-2xl font-bold">Your plays</h2>
39
+ <h2 className="text-2xl font-bold">Your public plays</h2>
40
{Array.from({ length: 6 }).map((_, i) => (
41
<SkeletonCard key={i} />
42
))}
frontend/splitfire-desktop/app/split/_components/button-split.tsx
new
+124
@@ -0,0 +1,124 @@
1
+import { HTTPStatusCode } from "@/app/_src/components/pages/esef/SplitFireView";
2
+import {
3
+ AudioFile,
4
+ Status,
5
+} from "@/app/_src/components/player/models/AudioFile";
6
+import { UserContext } from "@/app/_src/lib/CurrentUserContext";
7
+import requestSplitService, {
8
+ SplitResponse,
9
+} from "@/app/_src/lib/requestSplitService";
10
+import { CountdownTimerIcon, LapTimerIcon, RocketIcon } from "@radix-ui/react-icons";
11
+import { useRouter } from "next/navigation";
12
+import { useContext, useState } from "react";
13
+import { Spinner } from "react-bootstrap";
14
+
15
+enum State {
16
+ LOADING,
17
+ LOADED,
18
+ ERROR,
19
+}
20
+
21
+export default function ButtonGenerateBackingTracks(props: {
22
+ providerId: string;
23
+ audioFile: AudioFile | null;
24
+ aggregateVotes: number;
25
+}) {
26
+ const [state, setState] = useState(State.LOADED);
27
+ const { user } = useContext(UserContext);
28
+ const [goToLogin, setGoToLogin] = useState(false);
29
+ const [audioFile, setAudioFile] = useState<AudioFile | null>(null);
30
+ const router = useRouter();
31
+
32
+ useState(() => {
33
+ setAudioFile(props.audioFile);
34
+ });
35
+
36
+ const splitRequest = () => {
37
+ if (!user || !user.accessToken) {
38
+ setGoToLogin(true);
39
+ return;
40
+ }
41
+
42
+ setState(State.LOADING);
43
+ requestSplitService(props.providerId, user.accessToken)
44
+ .then((res) => {
45
+ console.log(res);
46
+ const response: SplitResponse = res.data;
47
+ if (response.code === HTTPStatusCode.OK) {
48
+ setAudioFile(response.audio_file);
49
+ setState(State.LOADED);
50
+ } else {
51
+ setState(State.ERROR);
52
+ }
53
+ })
54
+ .catch((error) => {
55
+ console.log(error);
56
+ setState(State.ERROR);
57
+ });
58
+ };
59
+
60
+ // Default to production value.
61
+ const splitTreshold = process.env.REACT_APP_SPLIT_THRESHOLD
62
+ ? parseInt(process.env.REACT_APP_SPLIT_THRESHOLD)
63
+ : 5;
64
+
65
+ const isDoneSplitting = audioFile && audioFile.status === Status.DONE;
66
+ const isCurrentlySplitting =
67
+ audioFile &&
68
+ (audioFile.status === Status.SPLITTING ||
69
+ audioFile.status === Status.DOWNLOADING);
70
+ const isReadyToSplit = props.aggregateVotes > splitTreshold;
71
+
72
+ if (goToLogin) {
73
+ router.push("/login");
74
+ return;
75
+ }
76
+
77
+ if (state === State.LOADING) {
78
+ return (
79
+ <div className="mb-3 mt-3">
80
+ <Spinner animation="border" role="status">
81
+ <span className="visually-hidden">Loading...</span>
82
+ </Spinner>
83
+ </div>
84
+ );
85
+ }
86
+ // Check if we have processed the split request.
87
+ if (isDoneSplitting) {
88
+ return (
89
+ <div className="mb-3 mt-3">
90
+ <h1 className="">Let's Play!</h1>
91
+ </div>
92
+ );
93
+ } else if (isCurrentlySplitting) {
94
+ return (
95
+ <div className="mb-3 mt-3" title="Split request in progress...">
96
+ <LapTimerIcon
97
+ width={40}
98
+ height={40}
99
+ className="mb-3"
100
+ color="red"
101
+ />
102
+ </div>
103
+ );
104
+ } else if (isReadyToSplit) {
105
+ return (
106
+ <div className="mb-3 mt-3"
107
+ title="Split request...">
108
+ <RocketIcon
109
+ width={40}
110
+ height={40}
111
+ className="mb-3 cursor-pointer"
112
+ onClick={splitRequest}
113
+ color="blue"
114
+ />
115
+ </div>
116
+ );
117
+ }
118
+
119
+ return (
120
+ <div className="mb-3 mt-3" title="Not enough votes to generate backing tracks...">
121
+ <CountdownTimerIcon width={40} height={40} className="mb-3" color="red" />
122
+ </div>
123
+ );
124
+}
frontend/splitfire-desktop/app/split/_components/lets-play.tsx
new
+16
@@ -0,0 +1,16 @@
1
+import { PlayIcon } from "@heroicons/react/solid";
2
+
3
+export default function LetsPlayView(props: { onClick: () => void }) {
4
+ const { onClick } = props;
5
+
6
+ return (
7
+ <div className="align-self-center col-auto">
8
+ <PlayIcon
9
+ onClick={onClick}
10
+ width={50}
11
+ cursor={"pointer"}
12
+ color="green"
13
+ />
14
+ </div>
15
+ );
16
+}
\ No newline at end of file
frontend/splitfire-desktop/app/split/_components/song-votes.tsx
new
+33
@@ -0,0 +1,33 @@
1
+"use client";
2
+
3
+import { SongProvider } from "@/app/_src/models/SongResponse";
4
+import { SongProviderVote } from "@/app/_src/models/SongVotesDetailResponse";
5
+import UpDownVotesView from "./votes-view";
6
+import Image from "next/image";
7
+
8
+export default function SongVotes({
9
+ songProvider,
10
+ votes,
11
+}: {
12
+ songProvider: SongProvider;
13
+ votes: SongProviderVote[];
14
+}) {
15
+ return (
16
+ <>
17
+ <h1 className="my-2">{songProvider.name}</h1>
18
+ <UpDownVotesView
19
+ votes={votes}
20
+ providerId={songProvider.id}
21
+ audioFile={songProvider.audio_file}
22
+ />
23
+ <Image
24
+ src={songProvider.image_url}
25
+ width={0}
26
+ height={0}
27
+ alt={songProvider.name}
28
+ sizes="100vw"
29
+ className="w-full h-auto aspect-video"
30
+ />
31
+ </>
32
+ );
33
+}
frontend/splitfire-desktop/app/split/_components/video.tsx
new
+24
@@ -0,0 +1,24 @@
1
+"use client";
2
+
3
+import YouTube, { Options } from "react-youtube";
4
+
5
+export function Video({ providerId }: { providerId: string }) {
6
+ const opts: Options = {
7
+ width: "100%",
8
+ height: "100%",
9
+ playerVars: {
10
+ // https://developers.google.com/youtube/player_parameters
11
+ autoplay: 0,
12
+ mute: 0,
13
+ controls: 0,
14
+ rel: 0,
15
+ showinfo: 0,
16
+ },
17
+ };
18
+
19
+ return (
20
+ <div className="mt-2 py-4 aspect-video flex-grow">
21
+ <YouTube videoId={providerId} opts={opts} />
22
+ </div>
23
+ );
24
+}
frontend/splitfire-desktop/app/split/_components/voters-view.tsx
new
+31
@@ -0,0 +1,31 @@
1
+import { VoteType } from "@/app/_src/components/pages/song/components/UpDownVotes";
2
+import { SongProviderVote } from "@/app/_src/models/SongVotesDetailResponse";
3
+import Image from "next/image";
4
+import Link from "next/link";
5
+
6
+export function VoterGravatarsViews(props: {
7
+ voters: SongProviderVote[];
8
+ type: VoteType;
9
+}) {
10
+ const className =
11
+ props.type === VoteType.DOWN
12
+ ? "justify-end"
13
+ : "justify-start";
14
+ return (
15
+ <div className={className}>
16
+ {props.voters.map((x, i) => {
17
+ return (
18
+ <Link href={`/profile?userId=@${x.user_id}`} key={i}>
19
+ <Image
20
+ src={x.voter_gravatar}
21
+ width={24}
22
+ height={24}
23
+ className="rounded-full"
24
+ alt={x.voter_username_or_id}
25
+ />
26
+ </Link>
27
+ );
28
+ })}
29
+ </div>
30
+ );
31
+}
frontend/splitfire-desktop/app/split/_components/votes-view-main.tsx
new
+43
@@ -0,0 +1,43 @@
1
+import { BsArrowUpCircleFill, BsArrowDownCircleFill } from "react-icons/bs";
2
+import { VoteState, VoteType } from "./votes-view";
3
+import { VoterGravatarsViews } from "./voters-view";
4
+
5
+export default function MainVotesView(props: {
6
+ voteState: VoteState;
7
+ vote: (type: VoteType) => void;
8
+}) {
9
+ const { voteState, vote } = props;
10
+
11
+ return (
12
+ <div className="w-full flex flex-col sm:flex-row flex-wrap sm:flex-nowrap py-4 flex-grow">
13
+ <div className="w-fixed w-full flex-shrink flex-grow-0 px-4 ">
14
+ <VoterGravatarsViews voters={voteState.upVotes} type={VoteType.UP} />
15
+ </div>
16
+ <div className="w-full flex-grow pt-1 px-3 flex justify-center">
17
+ <div className="grid-rows-3 gap-2">
18
+ <BsArrowUpCircleFill
19
+ size={40}
20
+ className="mb-3"
21
+ style={voteState.buttonUpStyle}
22
+ onClick={() => vote(VoteType.UP)}
23
+ color="green"
24
+ />
25
+ <h1 className="flex justify-center">{voteState.aggregate}</h1>
26
+ <BsArrowDownCircleFill
27
+ size={40}
28
+ className="mt-3"
29
+ style={voteState.buttonDownStyle}
30
+ onClick={() => vote(VoteType.DOWN)}
31
+ color="green"
32
+ />
33
+ </div>
34
+ </div>
35
+ <div className="w-fixed w-full flex-shrink flex-grow-0 px-2">
36
+ <VoterGravatarsViews
37
+ voters={voteState.downVotes}
38
+ type={VoteType.DOWN}
39
+ />
40
+ </div>
41
+ </div>
42
+ );
43
+}
frontend/splitfire-desktop/app/split/_components/votes-view.tsx
new
+203
@@ -0,0 +1,203 @@
1
+"use client";
2
+
3
+import {
4
+ HTTPStatusCode,
5
+ SongBridgeResponse,
6
+} from "@/app/_src/components/pages/esef/SplitFireView";
7
+import {
8
+ AudioFile,
9
+ Status,
10
+} from "@/app/_src/components/player/models/AudioFile";
11
+import { UserContext } from "@/app/_src/lib/CurrentUserContext";
12
+import { CurrentUser } from "@/app/_src/lib/db";
13
+import { SongProviderVote } from "@/app/_src/models/SongVotesDetailResponse";
14
+import axios from "axios";
15
+import { useRouter } from "next/navigation";
16
+import { CSSProperties, useState, useContext } from "react";
17
+import { Spinner } from "react-bootstrap";
18
+import ButtonGenerateBackingTracks from "./button-split";
19
+import MainVotesView from "./votes-view-main";
20
+import LetsPlayView from "./lets-play";
21
+
22
+enum State {
23
+ LOADING,
24
+ LOADED,
25
+ ERROR,
26
+}
27
+
28
+export enum VoteType {
29
+ UP = "up",
30
+ DOWN = "down",
31
+}
32
+
33
+export interface VoteState {
34
+ aggregate: number;
35
+ buttonUpStyle: CSSProperties;
36
+ buttonDownStyle: CSSProperties;
37
+ upVotes: SongProviderVote[];
38
+ downVotes: SongProviderVote[];
39
+}
40
+
41
+export default function UpDownVotesView({
42
+ providerId,
43
+ votes,
44
+ audioFile,
45
+}: {
46
+ providerId: string;
47
+ votes: SongProviderVote[];
48
+ audioFile: AudioFile | null;
49
+}) {
50
+ const [state, setState] = useState(State.LOADED);
51
+ const [goToLogin, setGoToLogin] = useState(false);
52
+ const [goToPlayer, setGoToPlayer] = useState(false);
53
+ const { user } = useContext(UserContext);
54
+ const router = useRouter();
55
+
56
+ const buttonStyle = (
57
+ votes: SongProviderVote[],
58
+ type: VoteType
59
+ ): CSSProperties => {
60
+ if (!user) {
61
+ return { cursor: "pointer" };
62
+ }
63
+
64
+ if (type === VoteType.UP) {
65
+ if (shouldDisableButton(votes, VoteType.UP, user)) {
66
+ return { pointerEvents: "none", opacity: "0.4" };
67
+ } else {
68
+ return { cursor: "pointer" };
69
+ }
70
+ } else {
71
+ if (shouldDisableButton(votes, VoteType.DOWN, user)) {
72
+ return { pointerEvents: "none", opacity: "0.4" };
73
+ } else {
74
+ return { cursor: "pointer" };
75
+ }
76
+ }
77
+ };
78
+
79
+ const calculateVotes = (votes: SongProviderVote[]): number => {
80
+ if (votes.length === 0) {
81
+ return 0;
82
+ }
83
+
84
+ const up = votes.filter((x) => x.vote_type === VoteType.UP).length;
85
+ const down = votes.filter((x) => x.vote_type === VoteType.DOWN).length;
86
+ //console.log('upvotes', up)
87
+ //console.log('downvotes', down)
88
+ const votesAggregate = up - down;
89
+ return votesAggregate;
90
+ };
91
+
92
+ const shouldDisableButton = (
93
+ votes: SongProviderVote[],
94
+ type: VoteType,
95
+ user: CurrentUser
96
+ ) => {
97
+ if (!user) {
98
+ return false;
99
+ }
100
+ const x = votes.filter((y) => {
101
+ if (y.user_id === user.user.id && y.vote_type === type) return true;
102
+ return false;
103
+ });
104
+ return x.length > 0;
105
+ };
106
+
107
+ const [voteState, setVoteState] = useState<VoteState>({
108
+ aggregate: calculateVotes(votes),
109
+ buttonUpStyle: buttonStyle(votes, VoteType.UP),
110
+ buttonDownStyle: buttonStyle(votes, VoteType.DOWN),
111
+ upVotes: votes.filter((x) => x.vote_type === VoteType.UP),
112
+ downVotes: votes.filter((x) => x.vote_type === VoteType.DOWN),
113
+ });
114
+
115
+ const vote = (type: VoteType) => {
116
+ if (!user || !user.accessToken || user.accessToken.length <= 1) {
117
+ setGoToLogin(true);
118
+ return;
119
+ }
120
+
121
+ setState(State.LOADING);
122
+ axios
123
+ .post(
124
+ `/song-bridge/${providerId}/vote`,
125
+ { vote_type: type, provider_id: providerId },
126
+ {
127
+ headers: {
128
+ "Content-Type": "application/json",
129
+ Authorization: user.accessToken,
130
+ },
131
+ }
132
+ )
133
+ .then((res) => {
134
+ const response: SongBridgeResponse = res.data;
135
+ //console.log(response.votes)
136
+ if (response.code === HTTPStatusCode.OK) {
137
+ setVoteState({
138
+ aggregate: calculateVotes(response.votes),
139
+ buttonUpStyle: buttonStyle(response.votes, VoteType.UP),
140
+ buttonDownStyle: buttonStyle(response.votes, VoteType.DOWN),
141
+ upVotes: response.votes.filter((x) => x.vote_type === VoteType.UP),
142
+ downVotes: response.votes.filter(
143
+ (x) => x.vote_type === VoteType.DOWN
144
+ ),
145
+ });
146
+ setState(State.LOADED);
147
+ } else {
148
+ console.log("error");
149
+ setState(State.ERROR);
150
+ }
151
+ })
152
+ .catch((error) => {
153
+ console.log(error);
154
+ setState(State.ERROR);
155
+ });
156
+ };
157
+
158
+ const isDoneSplitting = audioFile && audioFile.status === Status.DONE;
159
+
160
+ if (goToLogin) {
161
+ router.push("/login");
162
+ return;
163
+ }
164
+
165
+ if (goToPlayer) {
166
+ router.push(`/play?audioFileId=${audioFile?.id}`);
167
+ return;
168
+ }
169
+
170
+ if (state === State.LOADING) {
171
+ return (
172
+ <div className="mb-3" style={{ minHeight: 250 }}>
173
+ <Spinner animation="border" role="status">
174
+ <span className="visually-hidden">Loading...</span>
175
+ </Spinner>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ const mainView = isDoneSplitting ? (
181
+ <LetsPlayView onClick={() => setGoToPlayer(true)} />
182
+ ) : (
183
+ <MainVotesView voteState={voteState} vote={vote} />
184
+ );
185
+ const mainButton = (
186
+ <ButtonGenerateBackingTracks
187
+ providerId={providerId}
188
+ audioFile={audioFile}
189
+ aggregateVotes={voteState.aggregate}
190
+ />
191
+ );
192
+ // Votes treshold is passed, show the generate button.
193
+ return (
194
+ <div className="grid-rows-2">
195
+ <div className="flex justify-center">
196
+ {mainButton}
197
+ </div>
198
+ <div className="flex justify-center min-h-8">
199
+ {mainView}
200
+ </div>
201
+ </div>
202
+ );
203
+}
frontend/splitfire-desktop/app/split/layout.tsx
+1
-1
@@ -9,7 +9,7 @@ export default async function Layout({
9
<div className="space-y-9">
10
<div className="flex justify-between">
11
<div className="self-start">
12
- <h1 className="text-3xl font-bold">Split</h1>
12
+ <h1 className="text-3xl font-bold">Votes to play</h1>
13
</div>
14
</div>
15
<div>{children}</div>
frontend/splitfire-desktop/app/split/page.tsx
+20
-6
@@ -2,22 +2,32 @@
2
3
import { useSearchParams } from "next/navigation";
4
import { useLogger } from "../_src/lib/logger";
5
-import { useEffect } from "react";
5
+import { useEffect, useState } from "react";
6
import { invoke } from "@tauri-apps/api";
7
import { TAURI_CONTENT_SONG_BRIDGE_DETAIL } from "../_src/lib/tauriHandler";
8
+import { SongProvider } from "../_src/models/SongResponse";
9
+import { SongBridgeResponse } from "../_src/components/pages/esef/SplitFireView";
10
+import { SongProviderVote } from "../_src/models/SongVotesDetailResponse";
11
+import SongVotes from "./_components/song-votes";
12
13
export default function Page() {
14
const log = useLogger("Play");
15
const searchParams = useSearchParams();
16
const songProviderId = searchParams.get("songProviderId");
17
+ const [songProvider, setSongProvider] = useState<SongProvider | null>(null);
18
+ const [votes, setVotes] = useState<SongProviderVote[]>([]);
19
20
useEffect(() => {
21
log.debug("Play page loaded.");
22
async function fetchData() {
23
try {
18
- const rest = await invoke(TAURI_CONTENT_SONG_BRIDGE_DETAIL, { songProviderId });
24
+ const rest: SongBridgeResponse = await invoke(
25
+ TAURI_CONTENT_SONG_BRIDGE_DETAIL,
26
+ { songProviderId }
27
+ );
28
+ setSongProvider(rest.song_provider);
29
+ setVotes(rest.votes);
30
log.debug("PreparePlayerResponse", rest);
20
-
31
} catch (error) {
32
log.error(error);
33
}
@@ -26,11 +36,15 @@ export default function Page() {
36
// eslint-disable-next-line react-hooks/exhaustive-deps
37
}, []);
38
39
+ // Sanity check
40
+ if (!songProvider) {
41
+ log.error("No songProvider context");
42
+ return <div>No songProvider context</div>;
43
+ }
44
+
45
return (
46
<div className="prose prose-sm prose-invert max-w-none">
31
- <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
32
- Splitting file {songProviderId}
33
- </div>
47
+ <SongVotes songProvider={songProvider} votes={votes} />
48
</div>
49
);
50
}
frontend/splitfire-desktop/app/top-voted/layout.tsx
new
+13
@@ -0,0 +1,13 @@
1
+import React from 'react';
2
+
3
+export default async function Layout({
4
+ children,
5
+}: {
6
+ children: React.ReactNode;
7
+}) {
8
+ return (
9
+ <div className="space-y-9">
10
+ <div>{children}</div>
11
+ </div>
12
+ );
13
+}
frontend/splitfire-desktop/app/top-voted/page.tsx
new
+51
@@ -0,0 +1,51 @@
1
+"use client";
2
+
3
+import { ContentCarouselResponse } from "@/models/content";
4
+import { invoke } from "@tauri-apps/api";
5
+import { useState, useEffect } from "react";
6
+import { TAURI_CONTENT_TOP_VOTED } from "../_src/lib/tauriHandler";
7
+import { SongProvider } from "../_src/models/SongResponse";
8
+import { SkeletonCard, SongProviderCard, SongProviderPath } from "../_ui/skeleton-card";
9
+import { useLogger } from "../_src/lib/logger";
10
+
11
+export default function Page() {
12
+ const log = useLogger("TOp votes");
13
+ const [songProviders, setSongProviders] = useState<SongProvider[]>([]);
14
+
15
+ useEffect(() => {
16
+ log.debug("Top votes page loaded");
17
+ async function fetchData() {
18
+ try {
19
+ const response = await invoke<ContentCarouselResponse>(TAURI_CONTENT_TOP_VOTED);
20
+ console.log("Data", response);
21
+ setSongProviders(response.audio_files);
22
+ } catch (error) {
23
+ console.error("Failed to fetch data", error);
24
+ }
25
+ }
26
+ fetchData();
27
+ // eslint-disable-next-line react-hooks/exhaustive-deps
28
+ }, []);
29
+
30
+ return (
31
+ <div className="space-y-9">
32
+ <div className="flex justify-between">
33
+ <div className="self-start">
34
+ <h1 className="text-3xl font-bold">Top Voted</h1>
35
+ </div>
36
+ </div>
37
+ <div>
38
+ <div className="prose prose-sm prose-invert max-w-none">
39
+ <div className="grid grid-cols-3 gap-6">
40
+ { songProviders.length === 0 && Array.from({ length: 6 }).map((_, i) => (
41
+ <SkeletonCard key={i} isLoading={true} />
42
+ ))}
43
+ { songProviders.length > 0 && songProviders.map((SongProvider, i) => (
44
+ <SongProviderCard key={i} songProvider={SongProvider} path={SongProviderPath.SPLIT} />
45
+ ))}
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ );
51
+}
\ No newline at end of file
frontend/splitfire-desktop/models/content.ts
+1
@@ -1,6 +1,7 @@
1
import { SongProvider } from "@/app/_src/models/SongResponse";
2
import { TauriResponse } from "./shared";
3
4
+// Need to be renamed into more generic name
5
export interface ContentCarouselResponse {
6
status: TauriResponse,
7
message: string,
frontend/splitfire-desktop/package.json
+1
-1
@@ -40,7 +40,7 @@
40
"react-scripts": "5.0.0",
41
"react-textarea-autosize": "^8.5.3",
42
"react-transition-group": "4.x",
43
- "react-youtube": "^7.14.0",
43
+ "react-youtube": "^10.0.0",
44
"tailwind-merge": "^2.4.0",
45
"tailwindcss-animate": "^1.0.7",
46
"tone": "^14.7.77",
frontend/splitfire-desktop/src-tauri/src/command/constants.rs
+1
@@ -23,6 +23,7 @@ pub const PATH_AUDIO: &str = "api/v1/splitfire";
23
pub const PATH_ACCOUNT_LOGIN: &str = "api/v1/login";
24
pub const PATH_ACCOUNT_REGISTER: &str = "api/v1/register";
25
pub const PATH_ACCOUNT_LOGOUT: &str = "api/v1/logout";
26
+pub const PATH_ACCOUNT_PROFILE: &str = "api/v1/profile/{userId}";
27
28
// Contents Paths
29
pub const PATH_CAROUSEL: &str = "api/v1/carousel";
frontend/splitfire-desktop/src-tauri/src/main.rs
+3
-2
@@ -24,8 +24,8 @@ use app::{
24
},
25
content::{
26
__cmd__content_carousel, __cmd__content_ready_to_play,
27
- __cmd__content_song_bridge_detail, content_carousel, content_ready_to_play,
28
- content_song_bridge_detail,
27
+ __cmd__content_song_bridge_detail, __cmd__content_top_voted, content_carousel,
28
+ content_ready_to_play, content_song_bridge_detail, content_top_voted,
29
},
30
},
31
sfai_home_dir_path,
@@ -78,6 +78,7 @@ fn main() {
78
content_carousel,
79
content_ready_to_play,
80
content_song_bridge_detail,
81
+ content_top_voted
82
])
83
.run(tauri::generate_context!())
84
.expect("Error while running tauri application.");
frontend/splitfire-desktop/src-tauri/src/models/account.rs
+17
@@ -44,4 +44,21 @@ pub struct RegisterResponse {
44
pub code: i32,
45
pub message: String,
46
pub user: Option<User>,
47
+}
48
+
49
+#[derive(Serialize)]
50
+#[derive(Debug)]
51
+pub struct AccountProfileResponse {
52
+ pub status: TauriResponse,
53
+ pub message: String,
54
+ pub access_token: Option<String>,
55
+ pub user: Option<User>,
56
+}
57
+
58
+#[derive(Serialize, Deserialize)]
59
+#[derive(Debug)]
60
+pub struct ProfileResponse {
61
+ pub code: i32,
62
+ pub message: String,
63
+ pub user: Option<User>
64
}
\ No newline at end of file
frontend/splitfire-desktop/src-tauri/src/models/content.rs
+1
-1
@@ -53,7 +53,7 @@ pub struct SongBridgeResponse {
53
pub message: String,
54
pub error: Option<String>,
55
pub song_provider: Option<SongProvider>,
56
- pub votes: Vec<SongProviderVote>
56
+ pub votes: Option<Vec<SongProviderVote>>
57
}
58
59
#[derive(Serialize, Deserialize)]
frontend/splitfire-desktop/src-tauri/src/rest/content.rs
+55
-8
@@ -1,7 +1,12 @@
1
use crate::{
2
- command::constants::{base_url_builder, PATH_CAROUSEL, PATH_READY_TO_PLAY, PATH_SONG_BRIDGE_DETAIL},
2
+ command::constants::{
3
+ base_url_builder, PATH_CAROUSEL, PATH_READY_TO_PLAY, PATH_SONG_BRIDGE_DETAIL, PATH_TOP_VOTES,
4
+ },
5
models::{
4
- content::{CarouselResponse, ContentCarouselResponse, ContentSongBridgeResponse, SongBridgeResponse},
6
+ content::{
7
+ CarouselResponse, ContentCarouselResponse, ContentSongBridgeResponse,
8
+ SongBridgeResponse,
9
+ },
10
player::TauriResponse,
11
},
12
};
@@ -86,10 +91,9 @@ pub async fn content_ready_to_play() -> ContentCarouselResponse {
91
#[tauri::command]
92
pub async fn content_song_bridge_detail(song_provider_id: String) -> ContentSongBridgeResponse {
93
debug!("Song provider id: {:?}", song_provider_id);
89
- let response: Result<reqwest::Response, reqwest::Error> = Client::new()
90
- .get(content_url_builder(PATH_SONG_BRIDGE_DETAIL))
91
- .send()
92
- .await;
94
+ let url =
95
+ content_url_builder(PATH_SONG_BRIDGE_DETAIL).replace("{providerId}", &song_provider_id);
96
+ let response: Result<reqwest::Response, reqwest::Error> = Client::new().get(url).send().await;
97
98
let response = match response {
99
Ok(response) => response,
@@ -104,7 +108,7 @@ pub async fn content_song_bridge_detail(song_provider_id: String) -> ContentSong
108
}
109
};
110
107
- let res: SongBridgeResponse= match response.json().await {
111
+ let res: SongBridgeResponse = match response.json().await {
112
Ok(json) => json,
113
Err(e) => {
114
error!("Failed to parse response: {:?}", e);
@@ -118,11 +122,54 @@ pub async fn content_song_bridge_detail(song_provider_id: String) -> ContentSong
122
};
123
124
debug!("Song bridge detail: {:?}", res);
125
+
126
+ let votes = match res.votes {
127
+ Some(votes) => votes,
128
+ None => vec![],
129
+ };
130
+
131
ContentSongBridgeResponse {
132
code: TauriResponse::Success,
133
message: res.message,
134
song_provider: res.song_provider,
125
- votes: res.votes,
135
+ votes,
136
+ }
137
+}
138
+
139
+#[tauri::command]
140
+pub async fn content_top_voted() -> ContentCarouselResponse {
141
+ let response = Client::new()
142
+ .get(content_url_builder(PATH_TOP_VOTES))
143
+ .send()
144
+ .await;
145
+
146
+ let response = match response {
147
+ Ok(response) => response,
148
+ Err(e) => {
149
+ error!("Failed to get response: {:?}", e);
150
+ return ContentCarouselResponse {
151
+ status: TauriResponse::Error,
152
+ message: e.to_string(),
153
+ audio_files: vec![],
154
+ };
155
+ }
156
+ };
157
+ let res: CarouselResponse = match response.json().await {
158
+ Ok(json) => json,
159
+ Err(e) => {
160
+ error!("Failed to parse response: {:?}", e);
161
+ return ContentCarouselResponse {
162
+ status: TauriResponse::Error,
163
+ message: e.to_string(),
164
+ audio_files: vec![],
165
+ };
166
+ }
167
+ };
168
+ debug!("Top voted response: {:?}", res);
169
+ ContentCarouselResponse {
170
+ status: TauriResponse::Success,
171
+ message: res.message,
172
+ audio_files: res.audio_files,
173
}
174
}
175