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