Compare commits

..

12 Commits

Author SHA1 Message Date
2b62e01556 BackUp Stg 2026-03-12 11:31:02 +08:00
bipproduction
0dabc204bc Revert standalone output, keep serverExternalPackages fix
- Remove output: standalone to keep migration/seed workflow
- Restore original Dockerfile structure with full node_modules copy
- Keep serverExternalPackages for @elysiajs/static and elysia to fix
  the Html prerender error caused by dynamic import in @elysiajs/static
- Keep NODE_ENV=production before build step

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:53:18 +08:00
bipproduction
e8f8b51686 Fix build: externalize elysia packages, use standalone output, fix NODE_ENV
- Add serverExternalPackages for @elysiajs/static and elysia to prevent
  webpack from bundling dynamic imports that cause Html prerender error
- Use output: standalone for proper Docker deployment
- Comment out NODE_ENV=development in .env.example to avoid conflict
  with next build which requires NODE_ENV=production
- Set NODE_ENV=production before build step in Dockerfile
- Update runtime stage to use standalone output structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:48:05 +08:00
bipproduction
a4db3a149d Fix build error: move ViewTransitions inside body to fix 404 prerendering
ViewTransitions was wrapping the html element, which violates Next.js App
Router requirement that html and body be returned directly from root layout.
This caused prerendering of /404 to fail with Html import error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:29:29 +08:00
bipproduction
fece983ac5 Fix Dockerfile: remove non-existent gen:api script and fix build output paths
- Remove bun run gen:api which does not exist in package.json
- Change dist to .next for correct Next.js build output
- Replace non-existent generated/ with public/ for static assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:12:33 +08:00
8b7eef5fee New bunlock.b 2026-03-10 11:04:44 +08:00
8b22d01e0d FIx Docker File 2026-03-10 10:56:15 +08:00
dc13e37a02 Add .env.example and fix .gitignore to allow it
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-10 10:50:52 +08:00
2d2cbef29b Tambah File Docker 2026-03-10 10:42:51 +08:00
8c8a96b830 First Stg 2026-03-10 10:07:14 +08:00
dc3eccacbf First Stg 2026-03-10 10:03:33 +08:00
ffe94992e5 StaggingWeb 2026-03-09 16:44:42 +08:00
13 changed files with 348 additions and 243 deletions

19
.env Normal file
View File

@@ -0,0 +1,19 @@
DATABASE_URL="postgresql://bip:Production_123@localhost:5433/desa-darmasaba-v0.0.1?schema=public"
# Seafile
SEAFILE_TOKEN=20a19f4a04032215d50ce53292e6abdd38b9f806
SEAFILE_REPO_ID=f0e9ee4a-fd13-49a2-81c0-f253951d063a
SEAFILE_URL=https://cld-dkr-makuro-seafile.wibudev.com
SEAFILE_PUBLIC_SHARE_TOKEN=3a9a9ecb5e244f4da8ae
# Upload
WIBU_UPLOAD_DIR=uploads
WIBU_DOWNLOAD_DIR="./download"
NEXT_PUBLIC_BASE_URL="http://localhost:3000"
EMAIL_USER=nicoarya20@gmail.com
EMAIL_PASS=hymmfpcaqzqkfgbh
BASE_SESSION_KEY=kp9sGx91as0Kj2Ls81nAsl2Kdj13KsxP
BASE_TOKEN_KEY=Qm82JsA92lMnKw0291mxKaaP02KjslaA
# BOT-TELE
BOT_TOKEN=8498428675:AAEQwAUjTqpvgyyC5C123nP1mAxhOg12Ph0
CHAT_ID=5251328671

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# Database Configuration
DATABASE_URL="postgresql://username:password@localhost:5432/desa-darmasaba?schema=public"
# Seafile Configuration (File Storage)
SEAFILE_TOKEN=your_seafile_token
SEAFILE_REPO_ID=your_seafile_repo_id
SEAFILE_URL=https://your-seafile-instance.com
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
# Upload Configuration
WIBU_UPLOAD_DIR=uploads
WIBU_DOWNLOAD_DIR=./download
# Application Configuration
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Email Configuration (for notifications/subscriptions)
EMAIL_USER=your_email@gmail.com
EMAIL_PASS=your_email_app_password
# Session Configuration
BASE_SESSION_KEY=your_session_key_generate_secure_random_string
BASE_TOKEN_KEY=your_jwt_secret_key_generate_secure_random_string
# Telegram Bot Configuration (for notifications)
BOT_TOKEN=your_telegram_bot_token
CHAT_ID=your_telegram_chat_id
# Session Password (for iron-session)
SESSION_PASSWORD="your_session_password_min_32_characters_long_secure"
# ElevenLabs API Key (for TTS features - optional)
ELEVENLABS_API_KEY=your_elevenlabs_api_key
# Environment (optional, defaults to development)
# NODE_ENV=development

9
.gitignore vendored
View File

@@ -29,7 +29,12 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env # env
.env* # env local files (keep .env.example)
.env.local
.env*.local
.env.production
.env.development
!.env.example
# QC # QC
QC QC
@@ -52,7 +57,5 @@ next-env.d.ts
.github/ .github/
.env.*
*.tar.gz *.tar.gz

60
Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
# Stage 1: Build
FROM oven/bun:1.3 AS build
# Install build dependencies for native modules
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# Copy package files
COPY package.json bun.lock* ./
# Install dependencies
RUN bun install --frozen-lockfile
# Copy the rest of the application code
COPY . .
# Use .env.example as default env for build
RUN cp .env.example .env
# Generate Prisma client
RUN bun x prisma generate
# Build the application frontend
ENV NODE_ENV=production
RUN bun run build
# Stage 2: Runtime
FROM oven/bun:1.3-slim AS runtime
# Set environment variables
ENV NODE_ENV=production
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# Copy necessary files from build stage
COPY --from=build /app/package.json ./
COPY --from=build /app/tsconfig.json ./
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/src ./src
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
# Expose the port
EXPOSE 3000
# Start the application
CMD ["bun", "start"]

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,6 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
serverExternalPackages: ['@elysiajs/static', 'elysia'],
experimental: {}, experimental: {},
allowedDevOrigins: [ allowedDevOrigins: [
"http://192.168.1.82:3000", // buat akses dari HP/device lain "http://192.168.1.82:3000", // buat akses dari HP/device lain

View File

@@ -211,6 +211,9 @@ function ListKategoriPrestasi({ search }: { search: string }) {
</Stack> </Stack>
</Box> </Box>
{/* Modal Konfirmasi Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}

View File

@@ -403,6 +403,7 @@ export default function CreateMusik() {
Reset Reset
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"

View File

@@ -92,10 +92,10 @@ const MusicPlayer = () => {
} }
return ( return (
<Box px={{ base: 'xs', sm: 'md', md: 100 }} py="xl"> <Box px={{ base: 'md', md: 100 }} py="xl">
<Paper <Paper
mx="auto" mx="auto"
p={{ base: 'md', sm: 'xl' }} p="xl"
radius="lg" radius="lg"
shadow="sm" shadow="sm"
bg="white" bg="white"
@@ -105,52 +105,42 @@ const MusicPlayer = () => {
> >
<Stack gap="md"> <Stack gap="md">
<BackButton /> <BackButton />
<Flex <Group justify="space-between" mb="xl" mt={"md"}>
justify="space-between"
align={{ base: 'flex-start', sm: 'center' }}
direction={{ base: 'column', sm: 'row' }}
gap="md"
mb="xl"
mt="md"
>
<div> <div>
<Text fz={{ base: '24px', sm: '32px' }} fw={700} c="#0B4F78" lh={1.2}>Selamat Datang Kembali</Text> <Text size="32px" fw={700} c="#0B4F78">Selamat Datang Kembali</Text>
<Text size="sm" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text> <Text size="md" c="#5A6C7D">Temukan musik favorit Anda hari ini</Text>
</div> </div>
<TextInput <Group gap="md">
placeholder="Cari lagu..." <TextInput
leftSection={<IconSearch size={18} />} placeholder="Cari lagu..."
radius="xl" leftSection={<IconSearch size={18} />}
w={{ base: '100%', sm: 280 }} radius="xl"
value={search} w={280}
onChange={(e) => setSearch(e.target.value)} value={search}
styles={{ input: { backgroundColor: '#fff' } }} onChange={(e) => setSearch(e.target.value)}
/> styles={{ input: { backgroundColor: '#fff' } }}
</Flex> />
</Group>
</Group>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text> <Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
{currentSong ? ( {currentSong ? (
<Card radius="md" p={{ base: 'md', sm: 'xl' }} shadow="md" withBorder> <Card radius="md" p="xl" shadow="md">
<Flex <Group align="center" gap="xl">
direction={{ base: 'column', sm: 'row' }}
align="center"
gap={{ base: 'md', sm: 'xl' }}
>
<Avatar <Avatar
src={currentSong.coverImage?.link || '/mp3-logo.png'} src={currentSong.coverImage?.link || '/mp3-logo.png'}
size={120} size={180}
radius="md" radius="md"
/> />
<Stack gap="md" style={{ flex: 1, width: '100%' }}> <Stack gap="md" style={{ flex: 1 }}>
<Box ta={{ base: 'center', sm: 'left' }}> <div>
<Text fz={{ base: '20px', sm: '28px' }} fw={700} c="#0B4F78" lineClamp={1}>{currentSong.judul}</Text> <Text size="28px" fw={700} c="#0B4F78">{currentSong.judul}</Text>
<Text size="lg" c="#5A6C7D">{currentSong.artis}</Text> <Text size="lg" c="#5A6C7D">{currentSong.artis}</Text>
{currentSong.genre && ( {currentSong.genre && (
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge> <Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge>
)} )}
</Box> </div>
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text> <Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
<Slider <Slider
@@ -165,7 +155,7 @@ const MusicPlayer = () => {
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text> <Text size="xs" c="#5A6C7D" w={42}>{formatTime(duration || 0)}</Text>
</Group> </Group>
</Stack> </Stack>
</Flex> </Group>
</Card> </Card>
) : ( ) : (
<Card radius="md" p="xl" shadow="md"> <Card radius="md" p="xl" shadow="md">
@@ -185,29 +175,28 @@ const MusicPlayer = () => {
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}> <Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<Card <Card
radius="md" radius="md"
p="sm" p="md"
shadow="sm" shadow="sm"
withBorder
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
borderColor: currentSong?.id === song.id ? '#0B4F78' : 'transparent', border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
backgroundColor: currentSong?.id === song.id ? '#F0F7FA' : 'white',
transition: 'all 0.2s' transition: 'all 0.2s'
}} }}
onClick={() => playSong(song)} onClick={() => playSong(song)}
> >
<Group gap="sm" align="center" wrap="nowrap"> <Group gap="md" align="center">
<Avatar <Avatar
src={song.coverImage?.link || '/mp3-logo.png'} src={song.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
size={50} size={64}
radius="md" radius="md"
/> />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}> <Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text> <Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
<Text size="xs" c="#5A6C7D" truncate>{song.artis}</Text> <Text size="xs" c="#5A6C7D">{song.artis}</Text>
<Text size="xs" c="#8A9BA8">{song.durasi}</Text>
</Stack> </Stack>
{currentSong?.id === song.id && isPlaying && ( {currentSong?.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled" size="xs">Playing</Badge> <Badge color="#0B4F78" variant="filled">Memutar</Badge>
)} )}
</Group> </Group>
</Card> </Card>
@@ -218,42 +207,34 @@ const MusicPlayer = () => {
)} )}
</div> </div>
</Stack> </Stack>
</Stack> </Stack>
</Paper> </Paper>
{/* Control Player Section */}
<Paper <Paper
mt="xl" mt="xl"
mx="auto" mx="auto"
p={{ base: 'md', sm: 'xl' }} p="xl"
radius="lg" radius="lg"
shadow="sm" shadow="sm"
bg="white" bg="white"
style={{ style={{
border: '1px solid #eaeaea', border: '1px solid #eaeaea',
position: 'sticky',
bottom: 20,
zIndex: 10
}} }}
> >
<Flex <Flex align="center" justify="space-between" gap="xl" h="100%">
direction={{ base: 'column', md: 'row' }} <Group gap="md" style={{ flex: 1 }}>
align="center"
justify="space-between"
gap={{ base: 'md', md: 'xl' }}
>
{/* Song Info */}
<Group gap="md" style={{ flex: 1, width: '100%' }} wrap="nowrap">
<Avatar <Avatar
src={currentSong?.coverImage?.link || '/mp3-logo.png'} src={currentSong?.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
size={48} size={56}
radius="md" radius="md"
/> />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{currentSong ? ( {currentSong ? (
<> <>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text> <Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text>
<Text size="xs" c="#5A6C7D" truncate>{currentSong.artis}</Text> <Text size="xs" c="#5A6C7D">{currentSong.artis}</Text>
</> </>
) : ( ) : (
<Text size="sm" c="dimmed">Tidak ada lagu</Text> <Text size="sm" c="dimmed">Tidak ada lagu</Text>
@@ -261,31 +242,29 @@ const MusicPlayer = () => {
</div> </div>
</Group> </Group>
{/* Controls + Progress */} <Stack gap="xs" style={{ flex: 1 }} align="center">
<Stack gap="xs" style={{ flex: 2, width: '100%' }} align="center"> <Group gap="md">
<Group gap="sm">
<ActionIcon <ActionIcon
variant={isShuffle ? 'filled' : 'subtle'} variant={isShuffle ? 'filled' : 'subtle'}
color="#0B4F78" color="#0B4F78"
onClick={toggleShuffleHandler} onClick={toggleShuffleHandler}
radius="xl" radius="xl"
size={48}
> >
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />} {isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
</ActionIcon> </ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipBack}> <ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipBack}>
<IconPlayerSkipBackFilled size={20} /> <IconPlayerSkipBackFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="#0B4F78" color="#0B4F78"
size={48} size={56}
radius="xl" radius="xl"
onClick={togglePlayPauseHandler} onClick={togglePlayPauseHandler}
> >
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />} {isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
</ActionIcon> </ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={48} radius="xl" onClick={skipForward}> <ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipForward}>
<IconPlayerSkipForwardFilled size={20} /> <IconPlayerSkipForwardFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
@@ -293,7 +272,6 @@ const MusicPlayer = () => {
color="#0B4F78" color="#0B4F78"
onClick={toggleRepeatHandler} onClick={toggleRepeatHandler}
radius="xl" radius="xl"
size="md"
> >
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />} {isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon> </ActionIcon>
@@ -312,8 +290,7 @@ const MusicPlayer = () => {
</Group> </Group>
</Stack> </Stack>
{/* Volume Control - Hidden on mobile, shown on md and up */} <Group gap="xs" style={{ flex: 1 }} justify="flex-end">
<Group gap="xs" style={{ flex: 1 }} justify="flex-end" visibleFrom="md">
<ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}> <ActionIcon variant="subtle" color="gray" onClick={toggleMuteHandler}>
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />} {isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
</ActionIcon> </ActionIcon>

View File

@@ -93,19 +93,28 @@ export default function FixedPlayerBar() {
mt="md" mt="md"
style={{ style={{
position: 'fixed', position: 'fixed',
top: '50%', top: '50%', // Menempatkan titik atas ikon di tengah layar
left: '0px', left: '0px',
transform: 'translateY(-50%)', transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
borderBottomRightRadius: '20px', borderBottomRightRadius: '20px',
borderTopRightRadius: '20px', borderTopRightRadius: '20px',
cursor: 'pointer', cursor: 'pointer',
transition: 'transform 0.2s ease', transition: 'transform 0.2s ease',
zIndex: 1000 // Higher z-index zIndex: 1
}} }}
onClick={handleRestorePlayer} onClick={handleRestorePlayer}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(-50%)';
}}
> >
<IconMusic size={24} color="white" /> <IconMusic size={28} color="white" />
</Button> </Button>
{/* Spacer to prevent content from being hidden behind player */}
<Box h={20} />
</> </>
); );
} }
@@ -122,125 +131,132 @@ export default function FixedPlayerBar() {
bottom={0} bottom={0}
left={0} left={0}
right={0} right={0}
p={{ base: 'xs', sm: 'sm' }} p="sm"
shadow="xl" shadow="lg"
style={{ style={{
zIndex: 1000, zIndex: 1,
borderTop: '1px solid rgba(0,0,0,0.1)', borderTop: '1px solid rgba(0,0,0,0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}} }}
> >
<Flex align="center" gap={{ base: 'xs', sm: 'md' }} justify="space-between"> <Flex align="center" gap="md" justify="space-between">
{/* Song Info - Left */} {/* Song Info - Left */}
<Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap"> <Group gap="sm" flex={1} style={{ minWidth: 0 }}>
<Avatar <Avatar
src={currentSong.coverImage?.link || ''} src={currentSong.coverImage?.link || ''}
alt={currentSong.judul} alt={currentSong.judul}
size={"36"} size={40}
radius="sm" radius="sm"
imageProps={{ loading: 'lazy' }}
/> />
<Box style={{ minWidth: 0, flex: 1 }}> <Box style={{ minWidth: 0 }}>
<Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate> <Text fz="sm" fw={600} truncate>
{currentSong.judul} {currentSong.judul}
</Text> </Text>
<Text fz="10px" c="dimmed" truncate> <Text fz="xs" c="dimmed" truncate>
{currentSong.artis} {currentSong.artis}
</Text> </Text>
</Box> </Box>
</Group> </Group>
{/* Controls - Center */} {/* Controls + Progress - Center */}
<Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap"> <Group gap="xs" flex={2} justify="center">
{/* Shuffle - Desktop Only */} {/* Control Buttons */}
<ActionIcon <Group gap="xs">
variant={isShuffle ? 'filled' : 'subtle'} <ActionIcon
color={isShuffle ? '#0B4F78' : 'gray'} variant={isShuffle ? 'filled' : 'subtle'}
size={"md"} color={isShuffle ? 'blue' : 'gray'}
onClick={handleToggleShuffle} size="lg"
visibleFrom="sm" onClick={handleToggleShuffle}
> title="Shuffle"
<IconArrowsShuffle size={18} /> >
</ActionIcon> <IconArrowsShuffle size={18} />
</ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size={"md"} size="lg"
onClick={playPrev} onClick={playPrev}
> title="Previous"
<IconPlayerSkipBackFilled size={20} /> >
</ActionIcon> <IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="#0B4F78" color={isPlaying ? 'blue' : 'gray'}
size={"lg"} size="xl"
radius="xl" radius="xl"
onClick={togglePlayPause} onClick={togglePlayPause}
> title={isPlaying ? 'Pause' : 'Play'}
{isPlaying ? ( >
<IconPlayerPauseFilled size={24} /> {isPlaying ? (
) : ( <IconPlayerPauseFilled size={24} />
<IconPlayerPlayFilled size={24} /> ) : (
)} <IconPlayerPlayFilled size={24} />
</ActionIcon> )}
</ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size={"md"} size="lg"
onClick={playNext} onClick={playNext}
> title="Next"
<IconPlayerSkipForwardFilled size={20} /> >
</ActionIcon> <IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
{/* Repeat - Desktop Only */} <ActionIcon
<ActionIcon variant="subtle"
variant={isRepeat ? 'filled' : 'subtle'} color={isRepeat ? 'blue' : 'gray'}
color={isRepeat ? '#0B4F78' : 'gray'} size="lg"
size={"md"} onClick={toggleRepeat}
onClick={toggleRepeat} title={isRepeat ? 'Repeat On' : 'Repeat Off'}
visibleFrom="sm" >
> {isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />} </ActionIcon>
</ActionIcon> </Group>
{/* Progress Bar - Desktop Only */} {/* Progress Bar - Desktop */}
<Box w={150} ml="md" visibleFrom="md"> <Box w={200} display={{ base: 'none', md: 'block' }}>
<Slider <Slider
value={currentTime} value={currentTime}
max={duration || 100} max={duration || 100}
onChange={handleSeek} onChange={handleSeek}
size="xs" size="sm"
color="#0B4F78" color="blue"
label={(value) => formatTime(value)} label={(value) => formatTime(value)}
/> />
</Box> </Box>
</Group> </Group>
{/* Right Controls - Volume + Close */} {/* Right Controls - Volume + Close */}
<Group gap={4} flex={1} justify="flex-end" wrap="nowrap"> <Group gap="xs" flex={1} justify="flex-end">
{/* Volume Control - Tablet/Desktop */}
<Box <Box
onMouseEnter={() => setShowVolume(true)} onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)} onMouseLeave={() => setShowVolume(false)}
pos="relative" pos="relative"
visibleFrom="sm"
> >
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color={isMuted ? 'red' : 'gray'} color={isMuted ? 'red' : 'gray'}
size="lg" size="lg"
onClick={toggleMute} onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
> >
{isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />} {isMuted ? (
<IconVolumeOff size={18} />
) : (
<IconVolume size={18} />
)}
</ActionIcon> </ActionIcon>
<Transition <Transition
mounted={showVolume} mounted={showVolume}
transition="scale-y" transition="scale-y"
duration={200} duration={200}
timingFunction="ease"
> >
{(style) => ( {(style) => (
<Paper <Paper
@@ -249,8 +265,8 @@ export default function FixedPlayerBar() {
position: 'absolute', position: 'absolute',
bottom: '100%', bottom: '100%',
right: 0, right: 0,
marginBottom: '10px', mb: 'xs',
padding: '10px', p: 'sm',
zIndex: 1001, zIndex: 1001,
}} }}
shadow="md" shadow="md"
@@ -260,8 +276,8 @@ export default function FixedPlayerBar() {
value={isMuted ? 0 : volume} value={isMuted ? 0 : volume}
max={100} max={100}
onChange={handleVolumeChange} onChange={handleVolumeChange}
h={80} h={100}
color="#0B4F78" color="blue"
size="sm" size="sm"
/> />
</Paper> </Paper>
@@ -272,29 +288,30 @@ export default function FixedPlayerBar() {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size={"md"} size="lg"
onClick={handleMinimizePlayer} onClick={handleMinimizePlayer}
title="Minimize player"
> >
<IconX size={18} /> <IconX size={18} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Flex> </Flex>
{/* Progress Bar - Mobile (Base) */} {/* Progress Bar - Mobile */}
<Box px="xs" mt={4} hiddenFrom="md"> <Box mt="xs" display={{ base: 'block', md: 'none' }}>
<Slider <Slider
value={currentTime} value={currentTime}
max={duration || 100} max={duration || 100}
onChange={handleSeek} onChange={handleSeek}
size="xs" size="sm"
color="#0B4F78" color="blue"
label={(value) => formatTime(value)} label={(value) => formatTime(value)}
/> />
</Box> </Box>
</Paper> </Paper>
{/* Spacer to prevent content from being hidden behind player */} {/* Spacer to prevent content from being hidden behind player */}
<Box h={{ base: 70, sm: 80 }} /> <Box h={80} />
</> </>
); );
} }

View File

@@ -1,28 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Paper, Table, Title, Text } from '@mantine/core'; import { Paper, Table, Title } from '@mantine/core';
function Section({ title, data }: any) { function Section({ title, data }: any) {
if (!data || data.length === 0) return null; if (!data || data.length === 0) return null;
return ( return (
<> <>
<Table.Tr bg="gray.0"> <Table.Tr>
<Table.Td colSpan={2}> <Table.Td colSpan={2}>
<Text fw={700} fz={{ base: 'xs', sm: 'sm' }}>{title}</Text> <strong>{title}</strong>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
{data.map((item: any) => ( {data.map((item: any) => (
<Table.Tr key={item.id}> <Table.Tr key={item.id}>
<Table.Td> <Table.Td>
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}> {item.kode} - {item.uraian}
{item.kode} - {item.uraian}
</Text>
</Table.Td> </Table.Td>
<Table.Td ta="right"> <Table.Td ta="right">
<Text fz={{ base: 'xs', sm: 'sm' }} fw={500} style={{ whiteSpace: 'nowrap' }}> Rp {item.anggaran.toLocaleString('id-ID')}
Rp {item.anggaran.toLocaleString('id-ID')}
</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
@@ -43,24 +39,22 @@ export default function PaguTable({ apbdesData }: any) {
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan'); const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
return ( return (
<Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md"> <Paper withBorder p="md" radius="md">
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title> <Title order={5} mb="md">{title}</Title>
<Table.ScrollContainer minWidth={280}> <Table>
<Table verticalSpacing="xs"> <Table.Thead>
<Table.Thead> <Table.Tr>
<Table.Tr> <Table.Th>Uraian</Table.Th>
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th> <Table.Th ta="right">Anggaran (Rp)</Table.Th>
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Anggaran (Rp)</Table.Th> </Table.Tr>
</Table.Tr> </Table.Thead>
</Table.Thead> <Table.Tbody>
<Table.Tbody> <Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="1) PENDAPATAN" data={pendapatan} /> <Section title="2) BELANJA" data={belanja} />
<Section title="2) BELANJA" data={belanja} /> <Section title="3) PEMBIAYAAN" data={pembiayaan} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} /> </Table.Tbody>
</Table.Tbody> </Table>
</Table>
</Table.ScrollContainer>
</Paper> </Paper>
); );
} }

View File

@@ -30,62 +30,56 @@ export default function RealisasiTable({ apbdesData }: any) {
}; };
return ( return (
<Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md"> <Paper withBorder p="md" radius="md">
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title> <Title order={5} mb="md">{title}</Title>
{allRealisasiRows.length === 0 ? ( {allRealisasiRows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md"> <Text fz="sm" c="dimmed" ta="center" py="md">
Belum ada data realisasi Belum ada data realisasi
</Text> </Text>
) : ( ) : (
<Table.ScrollContainer minWidth={300}> <Table>
<Table verticalSpacing="xs"> <Table.Thead>
<Table.Thead> <Table.Tr>
<Table.Tr> <Table.Th>Uraian</Table.Th>
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th> <Table.Th ta="right">Realisasi (Rp)</Table.Th>
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Realisasi (Rp)</Table.Th> <Table.Th ta="center">%</Table.Th>
<Table.Th ta="center" fz={{ base: 'xs', sm: 'sm' }}>%</Table.Th> </Table.Tr>
</Table.Tr> </Table.Thead>
</Table.Thead> <Table.Tbody>
<Table.Tbody> {allRealisasiRows.map(({ realisasi, parentItem }) => {
{allRealisasiRows.map(({ realisasi, parentItem }) => { const persentase = parentItem.anggaran > 0
const persentase = parentItem.anggaran > 0 ? (realisasi.jumlah / parentItem.anggaran) * 100
? (realisasi.jumlah / parentItem.anggaran) * 100 : 0;
: 0;
return ( return (
<Table.Tr key={realisasi.id}> <Table.Tr key={realisasi.id}>
<Table.Td> <Table.Td>
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}> <Text>{realisasi.kode || '-'} - {realisasi.keterangan || '-'}</Text>
{realisasi.kode || '-'} - {realisasi.keterangan || '-'} </Table.Td>
</Text> <Table.Td ta="right">
</Table.Td> <Text fw={600} c="blue">
<Table.Td ta="right"> {formatRupiah(realisasi.jumlah || 0)}
<Text fw={600} c="blue" fz={{ base: 'xs', sm: 'sm' }} style={{ whiteSpace: 'nowrap' }}> </Text>
{formatRupiah(realisasi.jumlah || 0)} </Table.Td>
</Text> <Table.Td ta="center">
</Table.Td> <Badge
<Table.Td ta="center"> color={
<Badge persentase >= 100
size="sm" ? 'teal'
variant="light" : persentase >= 60
color={ ? 'yellow'
persentase >= 100 : 'red'
? 'teal' }
: persentase >= 60 >
? 'yellow' {persentase.toFixed(2)}%
: 'red' </Badge>
} </Table.Td>
> </Table.Tr>
{persentase.toFixed(1)}% );
</Badge> })}
</Table.Td> </Table.Tbody>
</Table.Tr> </Table>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)} )}
</Paper> </Paper>
); );

View File

@@ -99,13 +99,13 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<ViewTransitions> <html lang="id" {...mantineHtmlProps}>
<html lang="id" {...mantineHtmlProps}> <head>
<head> <meta charSet="utf-8" />
<meta charSet="utf-8" /> <ColorSchemeScript defaultColorScheme="light" />
<ColorSchemeScript defaultColorScheme="light" /> </head>
</head> <body>
<body> <ViewTransitions>
<MusicProvider> <MusicProvider>
<MantineProvider theme={theme} defaultColorScheme="light"> <MantineProvider theme={theme} defaultColorScheme="light">
{children} {children}
@@ -117,8 +117,8 @@ export default function RootLayout({
/> />
</MantineProvider> </MantineProvider>
</MusicProvider> </MusicProvider>
</body> </ViewTransitions>
</html> </body>
</ViewTransitions> </html>
); );
} }