Add Menu Musik

Add News Reader for Difable
Add Running text news / announcement
This commit is contained in:
2025-11-04 15:08:48 +08:00
parent d128313e71
commit fb57698dc9
21 changed files with 1328 additions and 48 deletions

View File

@@ -1,8 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import NewsReader from '@/app/darmasaba/_com/NewsReader';
import colors from '@/con/colors';
import { Box, Center, Container, Image, Skeleton, Stack, Text } from '@mantine/core';
import { Box, Center, Container, Group, Image, Skeleton, Stack, Text } from '@mantine/core';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
@@ -49,6 +50,9 @@ function Page() {
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"} px={{ base: "md", md: 0 }}>
<Group px={{ base: "md", md: 100 }}>
<NewsReader />
</Group>
<Container w={{ base: "100%", md: "50%" }} >
<Box pb={20}>
<Text ta={"center"} fz={"2.4rem"} c={colors["blue-button"]} fw={"bold"}>
@@ -67,6 +71,7 @@ function Page() {
<Box px={{ base: "md", md: 100 }}>
<Stack gap={"xs"}>
<Text
id='news-content'
py={20}
fz={{ base: "sm", md: "lg" }}
lh={{ base: 1.6, md: 1.8 }} // ✅ line-height lebih rapat dan responsif

View File

@@ -6,6 +6,7 @@ import { useShallowEffect } from '@mantine/hooks';
import { useParams } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import BackButton from '../../../layanan/_com/BackButto';
import NewsReader from '@/app/darmasaba/_com/NewsReader';
function Page() {
const detail = useProxy(stateDesaPengumuman.pengumuman.findUnique)
@@ -30,6 +31,9 @@ function Page() {
<BackButton />
</Box>
<Container size="lg" px="md">
<Group>
<NewsReader />
</Group>
<Stack gap="xs" >
<Group justify={"space-between"} align={"center"}>
<Text fz={{ base: "2rem", md: "2rem" }} c={colors["blue-button"]} fw="bold" >
@@ -42,7 +46,7 @@ function Page() {
</Group>
</Group>
<Paper bg={colors["white-1"]} p="md">
<Text fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: detail.data?.content }} />
<Text id='news-content' fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: detail.data?.content }} />
<Text fz={"md"} c={colors["blue-button"]} fw="bold" >
{new Date(detail.data?.createdAt).toLocaleDateString('id-ID', {
weekday: 'long',

View File

@@ -11,6 +11,7 @@ import ProfilPerbekel from './ui/profilPerbekel';
import MotoDesa from './ui/motoDesa';
import SemuaPerbekel from './ui/semuaPerbekel';
import ScrollToTopButton from '@/app/darmasaba/_com/scrollToTopButton';
import StrukturPerangkatDesa from './struktur-perangkat-desa/page';
function Page() {
return (
@@ -33,6 +34,7 @@ function Page() {
<LambangDesa />
<MaskotDesa />
<ProfilPerbekel />
<StrukturPerangkatDesa/>
<MotoDesa />
<SemuaPerbekel />
</Stack>

View File

@@ -0,0 +1,143 @@
'use client';
import stateStrukturPPID from '@/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID';
import colors from '@/con/colors';
import {
Box,
Divider,
Group,
Image,
Paper,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { useParams } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import BackButton from '../../_com/BackButto';
function DetailPegawaiUser() {
const statePegawai = useProxy(stateStrukturPPID.pegawai);
const params = useParams();
useShallowEffect(() => {
stateStrukturPPID.posisiOrganisasi.findMany.load();
statePegawai.findUnique.load(params?.id as string);
}, []);
if (!statePegawai.findUnique.data) {
return (
<Stack py="lg">
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = statePegawai.findUnique.data;
return (
<Box px={{ base: 'md', md: 100 }} py="xl">
{/* Back button */}
<Group mb="lg" px={{ base: 'md', md: 100 }}>
<BackButton/>
</Group>
<Paper
w={{ base: '100%', md: '70%' }}
mx="auto"
p="xl"
radius="lg"
shadow="sm"
bg="white"
style={{
border: '1px solid #eaeaea',
}}
>
<Stack align="center" gap="md">
{/* Foto Profil */}
<Image
src={data.image?.link || '/placeholder-profile.png'}
alt={data.namaLengkap || 'Foto Profil'}
w={160}
h={160}
radius={100}
fit="cover"
style={{ border: `2px solid ${colors['blue-button']}` }}
loading="lazy"
/>
{/* Nama & Jabatan */}
<Stack align="center" gap={2}>
<Title order={3} fw={700} c={colors['blue-button']}>
{data.namaLengkap || '-'} {data.gelarAkademik || ''}
</Title>
<Text fz="sm" c="dimmed">
{data.posisi?.nama || 'Posisi tidak tersedia'}
</Text>
</Stack>
</Stack>
<Divider my="lg" />
{/* Informasi Detail */}
<Stack gap="md">
<InfoRow label="Email" value={data.email} />
<InfoRow label="Telepon" value={data.telepon} />
<InfoRow label="Alamat" value={data.alamat} multiline />
<InfoRow
label="Tanggal Masuk"
value={
data.tanggalMasuk
? new Date(data.tanggalMasuk).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
: '-'
}
/>
<InfoRow
label="Status"
value={data.isActive ? 'Aktif' : 'Tidak Aktif'}
valueColor={data.isActive ? 'green' : 'red'}
/>
</Stack>
</Paper>
</Box>
);
}
/* Komponen kecil untuk menampilkan baris informasi */
function InfoRow({
label,
value,
valueColor,
multiline = false,
}: {
label: string;
value?: string | null;
valueColor?: string;
multiline?: boolean;
}) {
return (
<Box>
<Text fz="sm" fw={600} c="dark">
{label}
</Text>
<Text
fz="sm"
c={valueColor || 'dimmed'}
style={{
whiteSpace: multiline ? 'normal' : 'nowrap',
wordBreak: 'break-word',
}}
>
{value || '-'}
</Text>
</Box>
);
}
export default DetailPegawaiUser;

View File

@@ -0,0 +1,469 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import stateStrukturPPID from '@/app/admin/(dashboard)/_state/ppid/struktur_ppid/struktur_PPID'
import ScrollToTopButton from '@/app/darmasaba/_com/scrollToTopButton'
import colors from '@/con/colors'
import {
Box,
Button,
Card,
Center,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title,
Transition
} from '@mantine/core'
import {
IconArrowsMaximize,
IconArrowsMinimize,
IconRefresh,
IconSearch,
IconUsers,
IconZoomIn,
IconZoomOut,
} from '@tabler/icons-react'
import { debounce } from 'lodash'
import { useTransitionRouter } from 'next-view-transitions'
import { OrganizationChart } from 'primereact/organizationchart'
import { useEffect, useRef, useState } from 'react'
import { useProxy } from 'valtio/utils'
import './struktur.css'
import BackButton from '../_com/BackButto'
export default function StrukturPerangkatDesa() {
return (
<Box
style={{
minHeight: '100vh',
background: colors['Bg'],
color: '#E6F0FF',
paddingBottom: 48,
}}
>
<Box px={{ base: 'md', md: 100 }} py={"xl"}>
<BackButton />
<Stack align="center" gap="xl" mt="xl">
<Title
order={1}
ta="center"
c={colors['blue-button']}
fz={{ base: 28, md: 36, lg: 44 }}
>
Struktur Perangkat Desa
</Title>
<Text ta="center" c="black" maw={800}>
Gambaran visual peran dan pegawai yang ditugaskan. Arahkan kursor
untuk melihat detail atau klik node untuk fokus tampilan.
</Text>
</Stack>
<Box mt="lg">
<StrukturPerangkatDesaNode />
</Box>
</Box>
<ScrollToTopButton />
</Box>
)
}
function StrukturPerangkatDesaNode() {
const stateOrganisasi: any = useProxy(stateStrukturPPID.pegawai)
const router = useTransitionRouter()
const chartContainerRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
const [isFullscreen, setFullscreen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// debounce pencarian
const debouncedSearch = useRef(
debounce((value: string) => {
setSearchQuery(value)
}, 400)
).current
useEffect(() => {
void stateOrganisasi.findMany.load()
}, [])
const isLoading =
!stateOrganisasi.findMany.data && stateOrganisasi.findMany.loading !== false
if (isLoading) {
return (
<Center py={48}>
<Stack align="center" gap="sm">
<Loader size="lg" />
<Text fw={600}>Memuat struktur organisasi</Text>
<Text c="dimmed" size="sm">
Mengambil data pegawai dan posisi. Mohon tunggu sebentar.
</Text>
</Stack>
</Center>
)
}
const data = stateOrganisasi.findMany.data || []
if (data.length === 0) {
return (
<Center py={40}>
<Stack align="center" gap="md">
<Paper
radius="md"
p="xl"
style={{
width: 560,
background: 'rgba(28,110,164,0.2)',
border: `1px solid rgba(255,255,255,0.1)`,
textAlign: 'center',
}}
>
<Center>
<IconUsers size={56} />
</Center>
<Title order={3} mt="md">
Data pegawai belum tersedia
</Title>
<Text c="dimmed" mt="xs">
Belum ada data pegawai yang tercatat untuk PPID.
</Text>
<Group justify="center" mt="lg">
<Button
leftSection={<IconRefresh size={16} />}
variant="gradient"
gradient={{ from: 'indigo', to: 'cyan' }}
onClick={() => stateOrganisasi.findMany.load()}
>
Muat Ulang
</Button>
</Group>
</Paper>
</Stack>
</Center>
)
}
// 🧩 buat struktur organisasi
const posisiMap = new Map<string, any>()
const aktifPegawai = data.filter((p: any) => p.isActive)
for (const pegawai of aktifPegawai) {
const posisiId = pegawai.posisi.id
if (!posisiMap.has(posisiId)) {
posisiMap.set(posisiId, {
...pegawai.posisi,
pegawaiList: [],
children: [],
})
}
posisiMap.get(posisiId)!.pegawaiList.push(pegawai)
}
const root: any[] = []
posisiMap.forEach((posisi) => {
if (posisi.parentId) {
const parent = posisiMap.get(posisi.parentId)
if (parent) parent.children.push(posisi)
else root.push(posisi)
} else root.push(posisi)
})
const toOrgChartFormat = (node: any): any => {
const pegawai = node.pegawaiList?.[0]
return {
expanded: true,
data: {
id: pegawai?.id,
name: pegawai?.namaLengkap || 'Belum Ditugaskan',
title: node.nama || 'Tanpa Jabatan',
image: pegawai?.image?.link || '/img/default.png',
},
children: node.children?.map(toOrgChartFormat) || [],
}
}
let chartData = root.map(toOrgChartFormat)
// 🔍 filter by search
if (searchQuery) {
const filterNodes = (nodes: any[]): any[] =>
nodes
.map((n) => ({
...n,
children: filterNodes(n.children || []),
}))
.filter(
(n) =>
n.data.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
n.data.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
n.children.length > 0
)
chartData = filterNodes(chartData)
}
// 🎬 fullscreen & zoom control
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
chartContainerRef.current?.requestFullscreen()
setFullscreen(true)
} else {
document.exitFullscreen()
setFullscreen(false)
}
}
const handleZoomIn = () => setScale((s) => Math.min(s + 0.1, 2))
const handleZoomOut = () => setScale((s) => Math.max(s - 0.1, 0.5))
const resetZoom = () => setScale(1)
return (
<Stack align="center" mt="xl">
{/* 🔍 Controls */}
<Paper
shadow="xs"
p="md"
radius="md"
style={{
background: colors['blue-button']
}}
>
<Group gap="sm" wrap="wrap" justify="center">
<TextInput
placeholder="Cari nama atau jabatan..."
leftSection={<IconSearch size={16} />}
onChange={(e) => debouncedSearch(e.target.value)}
styles={{
input: {
minWidth: 250,
},
}}
/>
<Group gap="xs">
<Button
variant="light"
bg={colors['blue-button-2']}
size="sm"
onClick={handleZoomOut}
leftSection={<IconZoomOut size={16} />}
c={colors['blue-button']}
>
Zoom Out
</Button>
<Box
bg={colors['blue-button-2']}
c={colors['blue-button']}
px={16}
py={8}
style={{
fontSize: 14,
fontWeight: 700,
borderRadius: '8px',
minWidth: 70,
textAlign: 'center',
}}
>
{Math.round(scale * 100)}%
</Box>
<Button
bg={colors['blue-button-2']}
c={colors['blue-button']}
variant="light"
size="sm"
onClick={handleZoomIn}
leftSection={<IconZoomIn size={16} />}
>
Zoom In
</Button>
<Button
bg={colors['blue-button-2']}
c={colors['blue-button']}
variant="light"
size="sm"
onClick={resetZoom}
>
Reset
</Button>
<Button
bg={colors['blue-button-2']}
c={colors['blue-button']}
size="sm"
onClick={toggleFullscreen}
leftSection={
isFullscreen ? (
<IconArrowsMinimize size={16} />
) : (
<IconArrowsMaximize size={16} />
)
}
>
Fullscreen
</Button>
</Group>
</Group>
</Paper>
{/* 🧩 Chart Container */}
<Center style={{ width: '100%' }}>
<Box
ref={chartContainerRef}
style={{
overflowX: 'auto',
overflowY: 'auto',
width: '100%',
maxWidth: '100%',
padding: '32px 16px',
transition: 'transform 0.2s ease',
transform: `scale(${scale})`,
transformOrigin: 'center top',
}}
>
<OrganizationChart
value={chartData}
nodeTemplate={(node) => <NodeCard node={node} router={router} />}
className="p-organizationchart p-organizationchart-horizontal"
/>
</Box>
</Center>
</Stack>
)
}
function NodeCard({ node, router }: any) {
const imageSrc = node?.data?.image || '/img/default.png'
const name = node?.data?.name || 'Tanpa Nama'
const title = node?.data?.title || 'Tanpa Jabatan'
const hasId = Boolean(node?.data?.id)
return (
<Transition mounted transition="pop" duration={300}>
{(styles) => (
<Card
shadow="md"
radius="xl"
withBorder
style={{
...styles,
width: 240,
minHeight: 280,
padding: 20,
background: 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)',
borderColor: 'rgba(28, 110, 164, 0.3)',
borderWidth: 2,
transition: 'all 0.3s ease',
cursor: hasId ? 'pointer' : 'default',
}}
onMouseEnter={(e) => {
if (hasId) {
e.currentTarget.style.transform = 'translateY(-4px)'
e.currentTarget.style.boxShadow = '0 8px 24px rgba(28, 110, 164, 0.25)'
}
}}
onMouseLeave={(e) => {
if (hasId) {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = ''
}
}}
>
<Stack align="center" gap={12}>
{/* Photo */}
<Box
style={{
width: 96,
height: 96,
borderRadius: '50%',
overflow: 'hidden',
border: '3px solid rgba(28, 110, 164, 0.4)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
background: 'white',
}}
>
<Image
src={imageSrc}
alt={name}
width={96}
height={96}
fit="cover"
loading="lazy"
style={{
objectFit: 'cover',
}}
/>
</Box>
{/* Name */}
<Text
fw={700}
size="sm"
ta="center"
c={colors['blue-button']}
lineClamp={2}
style={{
minHeight: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
wordBreak: 'break-word',
lineHeight: 1.3,
}}
>
{name}
</Text>
{/* Title/Position */}
<Text
size="xs"
c="dimmed"
ta="center"
fw={500}
lineClamp={2}
style={{
minHeight: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
wordBreak: 'break-word',
lineHeight: 1.2,
}}
>
{title}
</Text>
{/* Detail Button */}
{hasId && (
<Button
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
size="xs"
fullWidth
mt={8}
radius="md"
onClick={() =>
router.push(`/darmasaba/desa/profile/struktur-perangkat-desa/${node.data.id}`)
}
style={{
height: 32,
fontWeight: 600,
}}
>
Lihat Detail
</Button>
)}
</Stack>
</Card>
)}
</Transition>
)
}

View File

@@ -0,0 +1,68 @@
/* ============================================
STRUKTUR ORGANISASI PPID - STYLING
============================================ */
/* Tabel chart selalu center */
.p-organizationchart-table {
margin: 0 auto !important;
}
/* Jarak vertikal antar level - lebih lega */
.p-organizationchart-line-down {
height: 32px !important;
}
/* Padding di dalam node - lebih rapi */
.p-organizationchart-node-content {
padding: 0 !important;
background: transparent !important;
border: none !important;
}
/* Garis connector antar node - lebih tebal dan jelas */
.p-organizationchart-line-down,
.p-organizationchart-line-left,
.p-organizationchart-line-right,
.p-organizationchart-line-top {
border-color: rgba(28, 110, 164, 0.4) !important;
border-width: 2px !important;
}
/* Garis horizontal */
.p-organizationchart-line-left,
.p-organizationchart-line-right {
border-top-width: 2px !important;
}
/* Jarak horizontal antar node - lebih proporsional */
.p-organizationchart-table > tbody > tr > td {
padding: 0 24px !important;
vertical-align: top !important;
}
/* Node container spacing */
.p-organizationchart-node {
padding: 8px !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.p-organizationchart-table > tbody > tr > td {
padding: 0 12px !important;
}
.p-organizationchart-line-down {
height: 24px !important;
}
}
/* Smooth transitions untuk zoom */
.p-organizationchart {
transition: transform 0.2s ease;
}
/* Fullscreen mode adjustments */
.p-organizationchart-table:fullscreen {
background: rgba(230, 240, 255, 0.98);
padding: 40px;
}

View File

@@ -0,0 +1,248 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, Slider, Stack, Text, TextInput } from '@mantine/core';
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import BackButton from '../../desa/layanan/_com/BackButto';
const MusicPlayer = () => {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(245);
const [volume, setVolume] = useState(70);
const [isMuted, setIsMuted] = useState(false);
const [isRepeat, setIsRepeat] = useState(false);
const [isShuffle, setIsShuffle] = useState(false);
const songs = [
{ id: 1, title: 'Midnight Dreams', artist: 'The Wanderers', duration: '4:05', cover: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop' },
{ id: 2, title: 'Summer Breeze', artist: 'Coastal Vibes', duration: '3:42', cover: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop' },
{ id: 3, title: 'City Lights', artist: 'Urban Echo', duration: '4:18', cover: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop' },
{ id: 4, title: 'Ocean Waves', artist: 'Serenity Sound', duration: '5:20', cover: 'https://images.unsplash.com/photo-1459749411175-04bf5292ceea?w=400&h=400&fit=crop' },
{ id: 5, title: 'Neon Nights', artist: 'Electric Dreams', duration: '3:55', cover: 'https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=400&h=400&fit=crop' },
{ id: 6, title: 'Mountain High', artist: 'Peak Performers', duration: '4:32', cover: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=400&h=400&fit=crop' }
];
const [currentSong, setCurrentSong] = useState(songs[0]);
useEffect(() => {
let interval: any;
if (isPlaying) {
interval = setInterval(() => {
setCurrentTime(prev => {
if (prev >= duration) {
setIsPlaying(false);
return 0;
}
return prev + 1;
});
}, 1000);
}
return () => clearInterval(interval);
}, [isPlaying, duration]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const playSong = (song: any) => {
setCurrentSong(song);
setCurrentTime(0);
setIsPlaying(true);
const durationInSeconds = parseInt(song.duration.split(':')[0]) * 60 + parseInt(song.duration.split(':')[1]);
setDuration(durationInSeconds);
};
const toggleMute = () => {
setIsMuted(!isMuted);
};
return (
<Box px={{ base: 'md', md: 100 }} py="xl">
<Paper
mx="auto"
p="xl"
radius="lg"
shadow="sm"
bg="white"
style={{
border: '1px solid #eaeaea',
}}
>
<Stack gap="md">
<BackButton />
<Group justify="space-between" mb="xl" mt={"md"}>
<div>
<Text size="32px" fw={700} c="#0B4F78">Selamat Datang Kembali</Text>
<Text size="md" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
</div>
<Group gap="md">
<TextInput
placeholder="Cari lagu..."
leftSection={<IconSearch size={18} />}
radius="xl"
w={280}
styles={{ input: { backgroundColor: '#fff' } }}
/>
</Group>
</Group>
<Stack gap="xl">
<div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
<Card radius="md" p="xl" shadow="md">
<Group align="center" gap="xl">
<Avatar src={currentSong.cover} size={180} radius="md" />
<Stack gap="md" style={{ flex: 1 }}>
<div>
<Text size="28px" fw={700} c="#0B4F78">{currentSong.title}</Text>
<Text size="lg" c="#5A6C7D">{currentSong.artist}</Text>
</div>
<Group gap="xs" align="center">
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
<Slider
value={currentTime}
max={duration}
onChange={setCurrentTime}
color="#0B4F78"
size="sm"
style={{ flex: 1 }}
styles={{ thumb: { borderWidth: 2 } }}
/>
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration)}</Text>
</Group>
</Stack>
</Group>
</Card>
</div>
<div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Daftar Putar</Text>
<Grid gutter="md">
{songs.map(song => (
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<Card
radius="md"
p="md"
shadow="sm"
style={{
cursor: 'pointer',
border: currentSong.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
transition: 'all 0.2s'
}}
onClick={() => playSong(song)}
>
<Group gap="md" align="center">
<Avatar src={song.cover} size={64} radius="md" />
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.title}</Text>
<Text size="xs" c="#5A6C7D">{song.artist}</Text>
<Text size="xs" c="#8A9BA8">{song.duration}</Text>
</Stack>
{currentSong.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled">Memutar</Badge>
)}
</Group>
</Card>
</Grid.Col>
))}
</Grid>
</div>
</Stack>
</Stack>
</Paper>
<Paper
mt="xl"
mx="auto"
p="xl"
radius="lg"
shadow="sm"
bg="white"
style={{
border: '1px solid #eaeaea',
}}
>
<Flex align="center" justify="space-between" gap="xl" h="100%">
<Group gap="md" style={{ flex: 1 }}>
<Avatar src={currentSong.cover} size={56} radius="md" />
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.title}</Text>
<Text size="xs" c="#5A6C7D">{currentSong.artist}</Text>
</div>
</Group>
<Stack gap="xs" style={{ flex: 1 }} align="center">
<Group gap="md">
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color="#0B4F78"
onClick={() => setIsShuffle(!isShuffle)}
radius="xl"
>
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
</ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl">
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="filled"
color="#0B4F78"
size={56}
radius="xl"
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
</ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl">
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
<ActionIcon
variant={isRepeat ? 'filled' : 'subtle'}
color="#0B4F78"
onClick={() => setIsRepeat(!isRepeat)}
radius="xl"
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
</Group>
<Group gap="xs" style={{ width: '100%', maxWidth: 500 }}>
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
<Slider
value={currentTime}
max={duration}
onChange={setCurrentTime}
color="#0B4F78"
size="xs"
style={{ flex: 1 }}
/>
<Text size="xs" c="#5A6C7D" w={40}>{formatTime(duration)}</Text>
</Group>
</Stack>
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
<ActionIcon variant="subtle" color="gray" onClick={toggleMute}>
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
</ActionIcon>
<Slider
value={isMuted ? 0 : volume}
onChange={(val) => {
setVolume(val);
if (val > 0) setIsMuted(false);
}}
color="#0B4F78"
size="xs"
w={100}
/>
<Text size="xs" c="#5A6C7D" w={32}>{isMuted ? 0 : volume}%</Text>
</Group>
</Flex>
</Paper>
</Box>
);
};
export default MusicPlayer;