Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: frontend improvements #100

Merged
merged 9 commits into from
Feb 1, 2025
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ jobs:
- name: Install dependencies
run: bun install

- name: Run lint
run: bun run lint

- name: Run tests
run: bun run test

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"dev:client": "vite",
"build:client": "vite build",
"preview:client": "vite preview",
"lint": "biome lint --write ./src"
"lint": "biome lint ./src",
"lint:write": "biome lint --write ./src"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
Expand Down
39 changes: 39 additions & 0 deletions src/client/components/AnswerButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ArrowCounterClockwise, Warning, Check, Star } from "@phosphor-icons/react";

export interface AnswerButtonsProps {
onAnswer: (ease: number) => void;
}

export default function AnswerButtons({ onAnswer }: AnswerButtonsProps) {
const answers = [
{ ease: 1, label: "Again", icon: <ArrowCounterClockwise size={24} />, color: "red" },
{ ease: 2, label: "Hard", icon: <Warning size={24} />, color: "yellow" },
{ ease: 3, label: "Good", icon: <Check size={24} />, color: "green" },
{ ease: 4, label: "Easy", icon: <Star size={24} />, color: "blue" },
];

return (
<div className="flex gap-2 mt-4 justify-center">
{answers.map(({ ease, label, icon, color }) => (
<button
key={ease}
type="button"
onClick={() => onAnswer(ease)}
className={`${
color === "red"
? "bg-red-500 hover:bg-red-600"
: color === "yellow"
? "bg-yellow-500 hover:bg-yellow-600"
: color === "green"
? "bg-green-500 hover:bg-green-600"
: "bg-blue-500 hover:bg-blue-600"
} text-white px-3 sm:px-4 py-2 rounded-full shadow-md transition transform hover:scale-105 min-w-[40px] sm:min-w-fit`}
title={label}
>
<span className="block sm:hidden">{icon}</span>
<span className="hidden sm:block">{label}</span>
</button>
))}
</div>
);
}
189 changes: 189 additions & 0 deletions src/client/components/AudioPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { useState, useEffect, useRef } from "react";
import { SkipBack, SkipForward, Pause, Play } from "@phosphor-icons/react";
import { getApiBase } from "../config";

export interface AudioPlayerProps {
audioUrls: string[];
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
currentAudioIndex: number;
setCurrentAudioIndex: (index: number) => void;
}

export default function AudioPlayer({
audioUrls,
isPlaying,
setIsPlaying,
currentAudioIndex,
setCurrentAudioIndex,
}: AudioPlayerProps) {
const audioRefs = useRef<HTMLAudioElement[]>([]);
const progressBarRef = useRef<HTMLButtonElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);

useEffect(() => {
const handleAudioEnd = () => {
if (currentAudioIndex < audioUrls.length - 1) {
setCurrentAudioIndex(currentAudioIndex + 1);
audioRefs.current[currentAudioIndex + 1]?.play();
} else {
setIsPlaying(false);
setCurrentAudioIndex(0);
}
};

const handleTimeUpdate = (audio: HTMLAudioElement) => {
setCurrentTime(audio.currentTime);
setDuration(audio.duration);
};

for (const audio of audioRefs.current) {
if (audio) {
audio.addEventListener("ended", handleAudioEnd);
audio.addEventListener("timeupdate", () => handleTimeUpdate(audio));
}
}

return () => {
for (const audio of audioRefs.current) {
if (audio) {
audio.removeEventListener("ended", handleAudioEnd);
audio.removeEventListener("timeupdate", () => handleTimeUpdate(audio));
}
}
};
}, [currentAudioIndex, audioUrls.length, setCurrentAudioIndex, setIsPlaying]);

const handleProgressBarClick = (event: React.MouseEvent<HTMLButtonElement>) => {
const progressBar = progressBarRef.current;
if (!progressBar || !audioRefs.current[currentAudioIndex]) return;

const rect = progressBar.getBoundingClientRect();
const clickPosition = (event.clientX - rect.left) / rect.width;
const newTime =
clickPosition * audioRefs.current[currentAudioIndex].duration;

audioRefs.current[currentAudioIndex].currentTime = newTime;
setCurrentTime(newTime);
};

const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};

const handlePlayPause = () => {
if (!audioUrls.length) return;

if (isPlaying) {
audioRefs.current[currentAudioIndex]?.pause();
setIsPlaying(false);
} else {
audioRefs.current[currentAudioIndex]?.play();
setIsPlaying(true);
}
};

return (
<div className="mb-4 bg-blue-600 dark:bg-gray-800 p-4 rounded-lg text-white">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm">
Track {currentAudioIndex + 1}/{audioUrls.length}
</span>
</div>
<div className="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>

<button
ref={progressBarRef}
className="h-2 bg-white/20 dark:bg-gray-600 rounded-full cursor-pointer relative"
onClick={handleProgressBarClick}
style={{ background: "none", border: "none", padding: 0 }}
type="button"
>
<div
className="absolute h-full bg-white/50 dark:bg-blue-500 rounded-full"
style={{ width: `${(currentTime / duration) * 100}%` }}
/>
</button>

<div className="flex justify-center items-center gap-4">
<button
type="button"
onClick={() => {
if (currentAudioIndex > 0) {
audioRefs.current[currentAudioIndex]?.pause();
setCurrentAudioIndex(currentAudioIndex - 1);
audioRefs.current[currentAudioIndex - 1].currentTime = 0;
if (isPlaying) {
audioRefs.current[currentAudioIndex - 1]?.play();
}
}
}}
className="text-white hover:text-white/70 dark:hover:text-blue-400 transition"
disabled={currentAudioIndex === 0}
title="Previous Track"
>
<SkipBack size={24} weight="fill" />
</button>
<button
type="button"
onClick={handlePlayPause}
className="w-12 h-12 flex items-center justify-center bg-white/25 dark:bg-blue-500 rounded-full hover:bg-white/40 dark:hover:bg-blue-600 transition transform hover:scale-105"
title={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<Pause size={24} weight="fill" />
) : (
<Play size={24} weight="fill" />
)}
</button>
<button
type="button"
onClick={() => {
if (currentAudioIndex < audioUrls.length - 1) {
audioRefs.current[currentAudioIndex]?.pause();
setCurrentAudioIndex(currentAudioIndex + 1);
audioRefs.current[currentAudioIndex + 1].currentTime = 0;
if (isPlaying) {
audioRefs.current[currentAudioIndex + 1]?.play();
}
}
}}
className="text-white hover:text-blue-400 transition"
disabled={currentAudioIndex === audioUrls.length - 1}
title="Next Track"
>
<SkipForward size={24} weight="fill" />
</button>
</div>
</div>

{audioUrls.map((url, index) => (
<audio
key={url}
ref={(el) => {
if (el) audioRefs.current[index] = el;
}}
className="hidden"
onCanPlay={() => {
if (index === 0 && !isPlaying) {
handlePlayPause();
}
}}
>
<source
src={`${getApiBase()}/audio/${url.replace("[sound:", "").replace("]", "")}`}
/>
<track default kind="captions" label="Captions" srcLang="en" src=""/>
</audio>
))}
</div>
);
}
57 changes: 57 additions & 0 deletions src/client/components/CardControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
ArrowLeft,
ArrowCounterClockwise,
ArrowRight,
} from "@phosphor-icons/react";

export interface CardControlsProps {
onPrevious: () => void;
onNext: () => void;
onRegenerate: () => void;
isFirst: boolean;
isLast: boolean;
isRegenerating: boolean;
}

export default function CardControls({
onPrevious,
onNext,
onRegenerate,
isFirst,
isLast,
isRegenerating,
}: CardControlsProps) {
return (
<div className="flex gap-2 justify-center items-center mt-4">
<button
type="button"
onClick={onPrevious}
disabled={isFirst}
className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 transition"
title="Previous Card"
>
<ArrowLeft size={20} weight="bold" />
</button>
<button
type="button"
onClick={onRegenerate}
disabled={isRegenerating}
className={`${
isRegenerating ? "bg-gray-300" : "bg-gray-500"
} text-white p-2 rounded hover:bg-gray-600 transition transform hover:scale-105`}
title="Regenerate card content and audio"
>
<ArrowCounterClockwise size={20} weight="bold" />
</button>
<button
type="button"
onClick={onNext}
disabled={isLast}
className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 transition"
title="Next Card"
>
<ArrowRight size={20} weight="bold" />
</button>
</div>
);
}
26 changes: 26 additions & 0 deletions src/client/components/DeckSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export interface DeckSelectorProps {
decks: string[];
selectedDeck: string | undefined;
onSelect: (event: React.ChangeEvent<HTMLSelectElement>) => void;
}

export default function DeckSelector({
decks,
selectedDeck,
onSelect,
}: DeckSelectorProps) {
return (
<select
value={selectedDeck || ""}
onChange={onSelect}
className="w-full p-2 mb-4 border rounded bg-gray-100 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="">Select a deck</option>
{decks?.map((deck) => (
<option key={deck} value={deck}>
{deck}
</option>
))}
</select>
);
}
Loading