Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Frontend CI

on:
push:
paths:
- "frontend/**"
- ".github/workflows/frontend-ci.yml"
pull_request:
paths:
- "frontend/**"
- ".github/workflows/frontend-ci.yml"

permissions:
contents: read

defaults:
run:
working-directory: frontend

jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.26.2
run_install: false

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22.14.0
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm lint

- name: Typecheck
run: pnpm typecheck

- name: Test and enforce coverage
run: pnpm test:coverage

- name: Build production bundle
run: pnpm build
45 changes: 30 additions & 15 deletions frontend/app/callback/page.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,53 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { exchangeCodeForToken } from "@/lib/spotify";
import { getCallbackError } from "@/lib/spotify-callback";
import { Suspense } from "react";

function CallbackHandler() {
const searchParams = useSearchParams();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const code = searchParams.get("code");
const authError = searchParams.get("error");
const [exchangeError, setExchangeError] = useState<string | null>(null);
const exchangeRef = useRef<{ code: string; promise: Promise<string> } | null>(null);

useEffect(() => {
const code = searchParams.get("code");
const authError = searchParams.get("error");

if (authError) {
setError(`Spotify authorization was denied: ${authError}`);
if (authError || !code) {
return;
}

if (!code) {
setError("No authorization code received from Spotify.");
return;
if (exchangeRef.current?.code !== code) {
exchangeRef.current = {
code,
promise: Promise.resolve().then(() => exchangeCodeForToken(code)),
};
}

exchangeCodeForToken(code)
const exchangePromise = exchangeRef.current.promise;
let cancelled = false;
exchangePromise
.then(() => {
if (cancelled) return;
// Redirect back to the main page — it will pick up the pending tracks
router.replace("/");
})
.catch((err) => {
setError(err.message || "Failed to complete Spotify login.");
if (cancelled) return;
setExchangeError(
err instanceof Error ? err.message : "Failed to complete Spotify login."
);
});
}, [searchParams, router]);

return () => {
cancelled = true;
};
}, [authError, code, router]);

const error = getCallbackError(authError, code, exchangeError);

return (
<div className="min-h-screen bg-base-300 flex items-center justify-center">
Expand All @@ -44,9 +59,9 @@ function CallbackHandler() {
Authorization Failed
</h2>
<p className="text-sm opacity-70 mb-6">{error}</p>
<a href="/" className="btn btn-primary">
<Link href="/" className="btn btn-primary">
Back to Home
</a>
</Link>
</>
) : (
<>
Expand Down
8 changes: 4 additions & 4 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ html {
.blog-eyebrow,
.blog-meta,
.blog-read-link {
font-family: "Capriola", sans-serif;
font-family: var(--font-capriola), sans-serif;
}

.blog-eyebrow {
Expand Down Expand Up @@ -313,15 +313,15 @@ html {
}
body {
@apply bg-background text-foreground;
font-family: "Capriola", sans-serif;
font-family: var(--font-capriola), sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: "Capriola", sans-serif;
font-family: var(--font-capriola), sans-serif;
}
}

.capriola-regular {
font-family: "Capriola", sans-serif;
font-family: var(--font-capriola), sans-serif;
font-weight: 400;
font-style: normal;
}
15 changes: 9 additions & 6 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { Metadata } from "next";
import { Capriola } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
import { SITE_URL } from "@/lib/site";
import "./globals.css";

const capriola = Capriola({
weight: "400",
subsets: ["latin"],
display: "swap",
variable: "--font-capriola",
});

export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
Expand Down Expand Up @@ -50,12 +58,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" data-theme="icecream">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Capriola&display=swap" rel="stylesheet" />
</head>
<html lang="en" data-theme="icecream" className={capriola.variable}>
<body className="antialiased">
{children}
<Analytics />
Expand Down
Loading
Loading