From ef0f82d516e105fde7b8810a94582c47d3b37320 Mon Sep 17 00:00:00 2001 From: Mark Hoekveen Date: Sat, 19 Jul 2025 10:50:41 +0200 Subject: [PATCH 1/4] first version of data fetcher --- frontend/package-lock.json | 11 ++++ frontend/package.json | 1 + frontend/src/app/users/page.tsx | 13 ++--- frontend/src/data/fetcher.ts | 91 +++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 frontend/src/data/fetcher.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eabb79d..683a9c3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "@next/font": "^14.2.15", "next": "^15.3.3", "oidc-client-ts": "^3.3.0", + "openapi-typescript-fetch": "^2.2.1", "react": "^19.1.0", "react-dom": "^19.1.0", "react-oidc-context": "^3.3.0" @@ -1532,6 +1533,16 @@ "node": ">=18" } }, + "node_modules/openapi-typescript-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/openapi-typescript-fetch/-/openapi-typescript-fetch-2.2.1.tgz", + "integrity": "sha512-aBp1cR5FTNxp4HA8bb2ST53aIqEiJgoOMyXiyzKi6YF7vogW8KkyyUQ1FeDz8D05uspxFrKvFbkVU2YiiKkULA==", + "license": "MIT", + "engines": { + "node": ">= 12.0.0", + "npm": ">= 7.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1c34c1b..def9f10 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,6 +3,7 @@ "@next/font": "^14.2.15", "next": "^15.3.3", "oidc-client-ts": "^3.3.0", + "openapi-typescript-fetch": "^2.2.1", "react": "^19.1.0", "react-dom": "^19.1.0", "react-oidc-context": "^3.3.0" diff --git a/frontend/src/app/users/page.tsx b/frontend/src/app/users/page.tsx index a031dcc..57a5729 100644 --- a/frontend/src/app/users/page.tsx +++ b/frontend/src/app/users/page.tsx @@ -1,20 +1,15 @@ "use client"; import { getUser } from "muzak/data/fetch"; import { useEffect, useState } from "react"; +import api from "muzak/data/fetcher"; export default function Page() { - const [userData, setUserData] = useState([]); + const [userData, setUserData] = useState([]); useEffect(() => { async function getUsers() { - const user = getUser(); - const access_token = user?.access_token; - const headers = new Headers(); - headers.set("Authorization", `Bearer ${access_token}`); - headers.set("Content-Type", "application/json; charset=utf-8"); - const users = await fetch("http://127.0.0.1:8001/api/user/", { headers }); - const userData = await users.json(); - setUserData(userData); + const { data: users } = await api.users.list({}); + setUserData(users); } getUsers(); }, []); diff --git a/frontend/src/data/fetcher.ts b/frontend/src/data/fetcher.ts new file mode 100644 index 0000000..9d7f2a3 --- /dev/null +++ b/frontend/src/data/fetcher.ts @@ -0,0 +1,91 @@ +"use client"; +import { Fetcher } from "openapi-typescript-fetch"; +import { getUser } from "./fetch"; + +interface GenericPaths { + [path: string]: { + [method: string]: any; + }; +} +const fetcher = Fetcher.for(); +const BASE_URL = process.env.BACKEND_HOST || "http://127.0.0.1:8001"; + +// Configure the fetcher +fetcher.configure({ + baseUrl: BASE_URL, + init: { + headers: { + "Content-Type": "application/json", + }, + }, + use: [ + // Authentication middleware + async (url, init, next) => { + const user = getUser(); + if (user?.access_token) { + init.headers = { + ...init.headers, + Authorization: `Bearer ${user.access_token}`, + }; + } + return next(url, init); + }, + // Error handling middleware + async (url, init, next) => { + const response = await next(url, init); + + if (!response.ok) { + // Handle authentication errors + if (response.status === 401) { + console.warn("Authentication failed - token may be expired"); + // You might want to trigger a redirect to login or token refresh here + } + + // Log other errors + console.error(`API Error: ${response.status} ${response.statusText}`, { + url, + method: init.method, + }); + } + + return response; + }, + ], +}); + +// Create your API methods +export const api = { + tracks: { + list: fetcher.path("/api/track/").method("get").create(), + detail: fetcher.path("/api/track/{id}").method("get").create(), + }, + artists: { + list: fetcher.path("/api/artist").method("get").create(), + detail: fetcher.path("/api/artist/{id}").method("get").create(), + }, + albums: { + list: fetcher.path("/api/album/").method("get").create(), + detail: fetcher.path("/api/album/{id}").method("get").create(), + }, + votes: { + list: fetcher.path("/api/vote/").method("get").create(), + detail: fetcher.path("/api/vote/{id}").method("get").create(), + userVotes: fetcher.path("/api/user/{user_id}/votes").method("get").create(), + }, + users: { + list: fetcher.path("/api/user/").method("get").create(), + detail: fetcher.path("/api/user/{id}").method("get").create(), + }, + profiles: { + detail: fetcher.path("/api/profile/{id}").method("get").create(), + backgrounds: { + list: fetcher.path("/api/profile/background").method("get").create(), + detail: fetcher + .path("/api/profile/background/{id}") + .method("get") + .create(), + }, + }, +}; + +export default api; From 7c92b33f91495d7d431aa7033bb7c39cbcf13bb1 Mon Sep 17 00:00:00 2001 From: Mark Hoekveen Date: Sat, 19 Jul 2025 18:37:41 +0200 Subject: [PATCH 2/4] adds type generation --- .gitignore | 1 + frontend/generate_types.sh | 16 ++++++++++++++++ frontend/package.json | 7 ++++--- frontend/src/app/users/page.tsx | 6 +++--- frontend/src/data/fetcher.ts | 22 +++++++++------------- 5 files changed, 33 insertions(+), 19 deletions(-) create mode 100755 frontend/generate_types.sh diff --git a/.gitignore b/.gitignore index 92f076d..79e9c33 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ albums/ wallpapers/ frontend/node_modules/ frontend/.next/ +frontend/src/data/types.ts diff --git a/frontend/generate_types.sh b/frontend/generate_types.sh new file mode 100755 index 0000000..a805c4b --- /dev/null +++ b/frontend/generate_types.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +if [ -z "$1" ]; then + set -- "BACKEND_HOST" +fi + +HOST=$(grep "$1" .env 2>/dev/null | cut -d '=' -f 2 | tr -d '"') +echo $1 +echo $HOST +cmd="npx openapi-typescript $HOST/api/schema/ -o src/data/types.tmp.ts" +if eval $cmd +then + echo $cmd + mv src/data/types.tmp.ts src/data/types.ts +fi +chmod go+rw src/data/types.ts diff --git a/frontend/package.json b/frontend/package.json index def9f10..87b8cea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,10 +9,11 @@ "react-oidc-context": "^3.3.0" }, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "npm run generate:types && next dev", + "build": "npm run generate:types && next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "generate:types": "bash generate_types.sh DOCKER_BACKEND" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.10", diff --git a/frontend/src/app/users/page.tsx b/frontend/src/app/users/page.tsx index 57a5729..c66aa9d 100644 --- a/frontend/src/app/users/page.tsx +++ b/frontend/src/app/users/page.tsx @@ -1,10 +1,10 @@ "use client"; import { getUser } from "muzak/data/fetch"; import { useEffect, useState } from "react"; -import api from "muzak/data/fetcher"; +import { api, User } from "muzak/data/fetcher"; export default function Page() { - const [userData, setUserData] = useState([]); + const [userData, setUserData] = useState([]); useEffect(() => { async function getUsers() { @@ -17,7 +17,7 @@ export default function Page() { return (
{userData.length > 0 ? ( - userData.map((user) =>
{user.email}
) + userData.map((user: User) =>
{user.email}
) ) : (
Loading users...
)} diff --git a/frontend/src/data/fetcher.ts b/frontend/src/data/fetcher.ts index 9d7f2a3..e6dd53e 100644 --- a/frontend/src/data/fetcher.ts +++ b/frontend/src/data/fetcher.ts @@ -1,13 +1,9 @@ "use client"; import { Fetcher } from "openapi-typescript-fetch"; import { getUser } from "./fetch"; +import { paths, components } from "muzak/data/types"; -interface GenericPaths { - [path: string]: { - [method: string]: any; - }; -} -const fetcher = Fetcher.for(); +const fetcher = Fetcher.for(); const BASE_URL = process.env.BACKEND_HOST || "http://127.0.0.1:8001"; // Configure the fetcher @@ -19,7 +15,7 @@ fetcher.configure({ }, }, use: [ - // Authentication middleware + // Add authentication token to request async (url, init, next) => { const user = getUser(); if (user?.access_token) { @@ -30,18 +26,14 @@ fetcher.configure({ } return next(url, init); }, - // Error handling middleware + // Handle errors async (url, init, next) => { const response = await next(url, init); if (!response.ok) { - // Handle authentication errors if (response.status === 401) { console.warn("Authentication failed - token may be expired"); - // You might want to trigger a redirect to login or token refresh here } - - // Log other errors console.error(`API Error: ${response.status} ${response.statusText}`, { url, method: init.method, @@ -53,7 +45,6 @@ fetcher.configure({ ], }); -// Create your API methods export const api = { tracks: { list: fetcher.path("/api/track/").method("get").create(), @@ -88,4 +79,9 @@ export const api = { }, }; +export type User = components["schemas"]["User"]; +export type Track = components["schemas"]["Track"]; +export type Album = components["schemas"]["Album"]; +export type Vote = components["schemas"]["Vote"]; + export default api; From 31c6fd3265780d126c1367b75395d225d0909d95 Mon Sep 17 00:00:00 2001 From: Mark Hoekveen Date: Sat, 19 Jul 2025 19:53:24 +0200 Subject: [PATCH 3/4] adds track loading example --- backend/playlist/models/track.py | 5 ++ backend/playlist/serializers/track.py | 5 +- backend/playlist/urls.py | 1 + backend/playlist/views/api/track.py | 14 ++++++ frontend/next.config.js | 7 +++ frontend/src/app/voting/page.tsx | 70 +++++++++++++++++++-------- frontend/src/data/fetcher.ts | 1 + 7 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 frontend/next.config.js diff --git a/backend/playlist/models/track.py b/backend/playlist/models/track.py index 8f0bc7a..764feda 100644 --- a/backend/playlist/models/track.py +++ b/backend/playlist/models/track.py @@ -104,6 +104,11 @@ class Track(models.Model): artists = list(map(lambda a:a.name, self.artists.all())) return " & ".join(artists) + @property + def album_cover(self): + if self.album: + return self.album.image_url + @property def name_sanitized(self): return re.match("[^\-\(]*\w", self.name)[0] diff --git a/backend/playlist/serializers/track.py b/backend/playlist/serializers/track.py index a985026..5fb642d 100644 --- a/backend/playlist/serializers/track.py +++ b/backend/playlist/serializers/track.py @@ -47,6 +47,9 @@ from drf_spectacular.utils import extend_schema_serializer, OpenApiExample ] ) class TrackSerializer(serializers.HyperlinkedModelSerializer): + artist = serializers.CharField() + album_cover = serializers.CharField() + class Meta: model = Track fields = '__all__' @@ -54,5 +57,5 @@ class TrackSerializer(serializers.HyperlinkedModelSerializer): 'url': {'view_name': 'api-track', 'lookup_field': 'id'}, 'artists': {'view_name': 'api-artist', 'lookup_field': 'id'}, 'album': {'view_name': 'api-album', 'lookup_field': 'id'}, - 'nominated_by': {'view_name': 'api-user', 'lookup_field': 'id'} + 'nominated_by': {'view_name': 'api-user', 'lookup_field': 'id'}, } diff --git a/backend/playlist/urls.py b/backend/playlist/urls.py index bda1144..6a3589c 100644 --- a/backend/playlist/urls.py +++ b/backend/playlist/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('api/track/', views.TrackListView.as_view(), name="api-tracks"), + path('api/track/vote', views.TrackVoteView.as_view(), name="api-track-vote"), path('api/track/', views.TrackDetailView.as_view(), name="api-track"), path('api/artist/', views.ArtistDetailView.as_view(), name="api-artist"), path('api/artist', views.ArtistListView.as_view(), name="api-artists"), diff --git a/backend/playlist/views/api/track.py b/backend/playlist/views/api/track.py index b3c11fd..40d22c6 100644 --- a/backend/playlist/views/api/track.py +++ b/backend/playlist/views/api/track.py @@ -5,6 +5,7 @@ from playlist.serializers import TrackSerializer from playlist.spotify import spt from playlist.tasks import get_banter, get_and_dither_image from drf_spectacular.utils import extend_schema +from random import choice class TrackListView(APIView): @extend_schema( @@ -64,3 +65,16 @@ class TrackDetailView(APIView): return Response(status=204) else: return Response({'error': 'Only superusers can delete tracks'}, status=403) + +class TrackVoteView(APIView): + @extend_schema( + request=None, + responses={200: TrackSerializer}, + description="Retrieve a random track for voting." + ) + def get(self, request): + # TODO implement a session-based vote tracking system. Random return for now. + random_pk = choice(Track.objects.values_list('pk', flat=True)) + track = Track.objects.get(pk=random_pk) + serializer = TrackSerializer(track, context={'request': request}) + return Response(serializer.data) diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..ea86c2b --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,7 @@ +const nextConfig = { + images: { + remotePatterns: [new URL("https://i.scdn.co/**")], + }, +}; + +export default nextConfig; diff --git a/frontend/src/app/voting/page.tsx b/frontend/src/app/voting/page.tsx index 009864d..6e9a36a 100644 --- a/frontend/src/app/voting/page.tsx +++ b/frontend/src/app/voting/page.tsx @@ -4,6 +4,7 @@ import { Nunito } from "next/font/google"; import users from "muzak/data/users.json"; import Image from "next/image"; import Link from "next/link"; +import { api, Track } from "muzak/data/fetcher"; const nunito = Nunito({ weight: "900", @@ -16,15 +17,25 @@ export default function Lobby() { const [yesVoteList, setYesVoteList] = useState([]); const [noVoteList, setNoVoteList] = useState([]); - const [currentSong, setCurrentSong] = useState({ - artist: "The Meowls", - title: "The Owls Are Not What They Seem", - }); + const [currentSong, setCurrentSong] = useState(); + const [songLoading, setSongLoading] = useState(false); useEffect(() => { populateUserList(); }, [partySize]); + async function getTrack() { + setSongLoading(true); + const { data: track } = await api.tracks.vote({}); + setCurrentSong(track); + console.log("I got a track!", track.name); + setSongLoading(false); + } + + useEffect(() => { + if (!currentSong && !songLoading) getTrack(); + }, []); + function populateUserList() { const mappedUsers = users.slice(0, partySize).map((user) => ({ id: user.id, @@ -50,7 +61,7 @@ export default function Lobby() {
- 30 +
@@ -86,10 +97,25 @@ export default function Lobby() {
))}{" "} - -
- meowl -
+ {!songLoading && currentSong ? ( +
+ {currentSong.name} +
+ ) : ( +
+ Loading.. +
+ )}
{userList.map((user) => (
-
-

- {currentSong.artist} -

-

- {currentSong.title} -

-
+ {!songLoading && currentSong && ( +
+

+ {currentSong.artist} +

+

+ {currentSong.name} +

+
+ )}
diff --git a/frontend/src/data/fetcher.ts b/frontend/src/data/fetcher.ts index e6dd53e..e0f7f65 100644 --- a/frontend/src/data/fetcher.ts +++ b/frontend/src/data/fetcher.ts @@ -49,6 +49,7 @@ export const api = { tracks: { list: fetcher.path("/api/track/").method("get").create(), detail: fetcher.path("/api/track/{id}").method("get").create(), + vote: fetcher.path("/api/track/vote").method("get").create(), }, artists: { list: fetcher.path("/api/artist").method("get").create(), From c3a7a2d2047e52bb3c0afaa931d5a4cd982cc7a3 Mon Sep 17 00:00:00 2001 From: Mark Hoekveen Date: Sun, 27 Jul 2025 10:26:40 +0200 Subject: [PATCH 4/4] adds data access, login flow, navigation menu. And some more stuff. :) --- frontend/src/app/layout.tsx | 16 ++++- frontend/src/app/page.tsx | 45 ++++++------ frontend/src/components/AuthStatus.tsx | 83 +++++++++++++++++++++++ frontend/src/components/NavigationBar.tsx | 24 +++++++ frontend/tsconfig.json | 3 +- 5 files changed, 145 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/AuthStatus.tsx create mode 100644 frontend/src/components/NavigationBar.tsx diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index a532c95..f34332c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -4,6 +4,8 @@ import { useState } from "react"; import { AuthProvider } from "react-oidc-context"; import { oidcConfig, userManager } from "muzak/config/auth"; import { Source_Sans_3, Nunito } from "next/font/google"; +import AuthStatus from "muzak/components/AuthStatus"; +import NavigationBar from "muzak/components/NavigationBar"; const sourceSans = Source_Sans_3({ weight: ["400"], @@ -14,6 +16,16 @@ const nunito = Nunito({ subsets: ["latin"], }); +function LayoutContent({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + + ); +} + export default function RootLayout({ children, }: { @@ -28,7 +40,9 @@ export default function RootLayout({ return ( - {children} + + {children} +
; - case "signoutRedirect": - return
Signing you out...
; - } - - if (auth.isLoading) { - return
Loading...
; - } - return (
- {auth.error &&
{auth.error.message}
} - {auth.isAuthenticated && ( -
-

Hello {auth.user?.profile.name}

-
- )} - {auth.isAuthenticated && ( - - )} - {!auth.isAuthenticated && ( - - )} - Meowl image +
+

+ Herzlich Willkommen bei Muzak! +

+ {auth.isAuthenticated && <>} + {!auth.isAuthenticated && ( +

+ Bitte authentificeren. +

+ )} +
); } diff --git a/frontend/src/components/AuthStatus.tsx b/frontend/src/components/AuthStatus.tsx new file mode 100644 index 0000000..cd3655f --- /dev/null +++ b/frontend/src/components/AuthStatus.tsx @@ -0,0 +1,83 @@ +"use client"; +import { useAuth } from "react-oidc-context"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Source_Sans_3, Nunito } from "next/font/google"; + +const nunito = Nunito({ + subsets: ["latin"], +}); + +export default function AuthStatus() { + const auth = useAuth(); + const router = useRouter(); + const [persistentAuthState, setPersistentAuthState] = useState<{ + isAuthenticated: boolean; + user: any; + hasInitialized: boolean; + }>({ + isAuthenticated: false, + user: null, + hasInitialized: false, + }); + + useEffect(() => { + // Only update persistent state when we have a definitive auth state + if (!auth.isLoading && !auth.error) { + setPersistentAuthState({ + isAuthenticated: auth.isAuthenticated, + user: auth.user, + hasInitialized: true, + }); + } + }, [auth.isLoading, auth.isAuthenticated, auth.user, auth.error]); + + // Show loading only on first load, not on subsequent navigations + if (!persistentAuthState.hasInitialized && auth.isLoading) { + return ( +
+ ... +
+ ); + } + + const displayState = persistentAuthState.hasInitialized + ? persistentAuthState + : { isAuthenticated: auth.isAuthenticated, user: auth.user }; + + return ( +
+ {displayState.isAuthenticated ? ( + <> +
+ + {displayState.user?.profile.name || "Authenticated"} + + + + ) : ( + <> +
+ + + )} +
+ ); +} diff --git a/frontend/src/components/NavigationBar.tsx b/frontend/src/components/NavigationBar.tsx new file mode 100644 index 0000000..5a4ccdd --- /dev/null +++ b/frontend/src/components/NavigationBar.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import { useAuth } from "react-oidc-context"; +import { Source_Sans_3, Nunito } from "next/font/google"; +const source = Source_Sans_3({ + subsets: ["latin"], +}); +const font = source.className; + +export default function NavigationBar() { + const auth = useAuth(); + if (!auth.isLoading && !auth.error && auth.isAuthenticated) { + return ( + + ); + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index e3e4f1c..1317fc2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -8,7 +8,8 @@ "paths": { "muzak/config/*": ["./src/config/*"], "muzak/data/*": ["./src/data/*"], - "muzak/app/*": ["./src/app/*"] + "muzak/app/*": ["./src/app/*"], + "muzak/components/*": ["./src/components/*"] }, "allowJs": false,