Skip to content

Commit ba0ef2d

Browse files
Merge pull request #1 from florianbussmann/feat/audio-player
feat: audio player
2 parents 282aa79 + b13d610 commit ba0ef2d

File tree

12 files changed

+792
-70
lines changed

12 files changed

+792
-70
lines changed

app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const geistMono = Geist_Mono({
1313
});
1414

1515
export const metadata: Metadata = {
16-
title: "Create Next App",
16+
title: "Repattern",
1717
description: "Generated by create next app",
1818
};
1919

app/page.tsx

Lines changed: 37 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,37 @@
1-
import Image from "next/image";
1+
'use client'
2+
3+
import { useState, useEffect } from "react";
4+
import { AudioCard } from "@/components/AudioCard";
5+
import { AudioPlayer } from "@/components/AudioPlayer";
6+
import { audioFiles } from "@/data/audioFiles";
27

38
export default function Home() {
4-
return (
5-
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
6-
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
7-
<Image
8-
className="dark:invert"
9-
src="next.svg"
10-
alt="Next.js logo"
11-
width={180}
12-
height={38}
13-
priority
14-
/>
15-
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
16-
<li className="mb-2 tracking-[-.01em]">
17-
Get started by editing{" "}
18-
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
19-
app/page.tsx
20-
</code>
21-
.
22-
</li>
23-
<li className="tracking-[-.01em]">
24-
Save and see your changes instantly.
25-
</li>
26-
</ol>
9+
const [selectedAudio, setSelectedAudio] = useState<string | null>(null);
10+
11+
const handleAudioSelect = (audioId: string) => {
12+
setSelectedAudio(audioId);
13+
};
2714

28-
<div className="flex gap-4 items-center flex-col sm:flex-row">
29-
<a
30-
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
31-
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
32-
target="_blank"
33-
rel="noopener noreferrer"
34-
>
35-
<Image
36-
className="dark:invert"
37-
src="vercel.svg"
38-
alt="Vercel logomark"
39-
width={20}
40-
height={20}
41-
/>
42-
Deploy now
43-
</a>
44-
<a
45-
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
46-
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
47-
target="_blank"
48-
rel="noopener noreferrer"
49-
>
50-
Read our docs
51-
</a>
15+
const selectedAudioFile = audioFiles.find(a => a.id === selectedAudio);
16+
17+
return (
18+
<div className="font-sans grid items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
19+
<main className="flex flex-col items-center sm:items-start">
20+
<div>
21+
<h2 className="text-xl font-semibold mb-4">Training Library</h2>
22+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
23+
{audioFiles.map((audio) => (
24+
<AudioCard
25+
key={audio.id}
26+
id={audio.id}
27+
title={audio.title}
28+
duration={audio.duration}
29+
category={audio.category}
30+
isActive={selectedAudio === audio.id}
31+
onClick={() => handleAudioSelect(audio.id)}
32+
/>
33+
))}
34+
</div>
5235
</div>
5336
</main>
5437
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
@@ -58,13 +41,6 @@ export default function Home() {
5841
target="_blank"
5942
rel="noopener noreferrer"
6043
>
61-
<Image
62-
aria-hidden
63-
src="file.svg"
64-
alt="File icon"
65-
width={16}
66-
height={16}
67-
/>
6844
Learn
6945
</a>
7046
<a
@@ -73,13 +49,6 @@ export default function Home() {
7349
target="_blank"
7450
rel="noopener noreferrer"
7551
>
76-
<Image
77-
aria-hidden
78-
src="window.svg"
79-
alt="Window icon"
80-
width={16}
81-
height={16}
82-
/>
8352
Examples
8453
</a>
8554
<a
@@ -88,16 +57,15 @@ export default function Home() {
8857
target="_blank"
8958
rel="noopener noreferrer"
9059
>
91-
<Image
92-
aria-hidden
93-
src="globe.svg"
94-
alt="Globe icon"
95-
width={16}
96-
height={16}
97-
/>
9860
Go to nextjs.org →
9961
</a>
10062
</footer>
63+
{selectedAudioFile && (
64+
<AudioPlayer
65+
src={selectedAudioFile.src}
66+
title={selectedAudioFile.title}
67+
/>
68+
)}
10169
</div>
10270
);
10371
}

components/AudioCard.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Check, Clock } from "lucide-react";
2+
import { Card } from "@/components/ui/card";
3+
import { Badge } from "@/components/ui/badge";
4+
import { cn } from "@/lib/utils";
5+
6+
interface AudioCardProps {
7+
id: string;
8+
title: string;
9+
duration: string;
10+
category: string;
11+
isActive: boolean;
12+
onClick: () => void;
13+
}
14+
15+
export const AudioCard = ({
16+
title,
17+
duration,
18+
category,
19+
isActive,
20+
onClick,
21+
}: AudioCardProps) => {
22+
return (
23+
<Card
24+
onClick={onClick}
25+
className={cn(
26+
"group relative overflow-hidden cursor-pointer transition-all duration-300",
27+
"hover:shadow-glow hover:scale-[1.02] hover:border-primary/50",
28+
isActive && "border-primary shadow-glow scale-[1.02]"
29+
)}
30+
>
31+
<div className="p-6">
32+
<div className="flex items-start justify-between mb-4">
33+
<div className="flex-1">
34+
<h3 className="font-semibold text-lg mb-2 group-hover:text-primary transition-colors">
35+
{title}
36+
</h3>
37+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
38+
<Clock className="h-4 w-4" />
39+
<span>{duration}</span>
40+
</div>
41+
</div>
42+
43+
<div className="flex flex-col items-end gap-2">
44+
<Badge variant="outline" className="bg-gradient-primary">
45+
{category}
46+
</Badge>
47+
</div>
48+
</div>
49+
50+
<div className={cn(
51+
"absolute inset-0 bg-gradient-primary opacity-0 transition-opacity duration-300",
52+
"group-hover:opacity-5"
53+
)} />
54+
</div>
55+
</Card>
56+
);
57+
};

components/AudioPlayer.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useState, useRef, useEffect } from "react";
2+
import { Play, Pause } from "lucide-react";
3+
import { Button } from "@/components/ui/button";
4+
import { cn } from "@/lib/utils";
5+
6+
interface AudioPlayerProps {
7+
src: string;
8+
title: string;
9+
}
10+
11+
export const AudioPlayer = ({ src, title }: AudioPlayerProps) => {
12+
const [isPlaying, setIsPlaying] = useState(false);
13+
const [currentTime, setCurrentTime] = useState(0);
14+
const [duration, setDuration] = useState(0);
15+
const audioRef = useRef<HTMLAudioElement>(null);
16+
17+
useEffect(() => {
18+
const audio = audioRef.current;
19+
if (!audio) return;
20+
21+
const handleLoadedMetadata = () => {
22+
setDuration(audio.duration);
23+
setIsPlaying(false);
24+
};
25+
26+
const handleTimeUpdate = () => {
27+
setCurrentTime(audio.currentTime);
28+
};
29+
30+
const handleEnded = () => {
31+
setIsPlaying(false);
32+
};
33+
34+
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
35+
audio.addEventListener("timeupdate", handleTimeUpdate);
36+
audio.addEventListener("ended", handleEnded);
37+
38+
return () => {
39+
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
40+
audio.removeEventListener("timeupdate", handleTimeUpdate);
41+
audio.removeEventListener("ended", handleEnded);
42+
};
43+
});
44+
45+
const togglePlay = () => {
46+
const audio = audioRef.current;
47+
if (!audio) return;
48+
49+
if (isPlaying) {
50+
audio.pause();
51+
} else {
52+
audio.play();
53+
}
54+
setIsPlaying(!isPlaying);
55+
};
56+
57+
const formatTime = (time: number) => {
58+
const minutes = Math.floor(time / 60);
59+
const seconds = Math.floor(time % 60);
60+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
61+
};
62+
63+
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
64+
65+
return (
66+
<div className="fixed bottom-0 left-0 right-0 flex border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base px-4 sm:px-5 sm:w-autor">
67+
<audio ref={audioRef} src={src} />
68+
69+
<div className="w-2/3">
70+
<h3 className="text-lg font-semibold text-background mb-2">{title}</h3>
71+
</div>
72+
<div className="w-1/3">
73+
<div className="h-2 rounded-full overflow-hidden">
74+
<div
75+
className="h-full bg-muted transition-all duration-300"
76+
style={{ width: `${progress}%` }}
77+
/>
78+
</div>
79+
<div className="flex justify-between mt-2 text-sm text-muted-foreground">
80+
<span>{formatTime(currentTime)}</span>
81+
<span>{formatTime(duration)}</span>
82+
</div>
83+
</div>
84+
85+
<div className="flex items-center justify-center gap-3">
86+
<Button
87+
size="icon"
88+
onClick={togglePlay}
89+
className={cn(
90+
"h-14 w-14 rounded-full shadow-glow transition-all hover:scale-105",
91+
"bg-gradient-primary"
92+
)}
93+
>
94+
{isPlaying ? (
95+
<Pause className="h-6 w-6" fill="currentColor" />
96+
) : (
97+
<Play className="h-6 w-6 ml-1" fill="currentColor" />
98+
)}
99+
</Button>
100+
</div>
101+
</div>
102+
);
103+
};

components/ui/badge.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as React from "react"
2+
import { Slot } from "@radix-ui/react-slot"
3+
import { cva, type VariantProps } from "class-variance-authority"
4+
5+
import { cn } from "@/lib/utils"
6+
7+
const badgeVariants = cva(
8+
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
9+
{
10+
variants: {
11+
variant: {
12+
default:
13+
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14+
secondary:
15+
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16+
destructive:
17+
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18+
outline:
19+
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20+
},
21+
},
22+
defaultVariants: {
23+
variant: "default",
24+
},
25+
}
26+
)
27+
28+
function Badge({
29+
className,
30+
variant,
31+
asChild = false,
32+
...props
33+
}: React.ComponentProps<"span"> &
34+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35+
const Comp = asChild ? Slot : "span"
36+
37+
return (
38+
<Comp
39+
data-slot="badge"
40+
className={cn(badgeVariants({ variant }), className)}
41+
{...props}
42+
/>
43+
)
44+
}
45+
46+
export { Badge, badgeVariants }

0 commit comments

Comments
 (0)