Compare commits

..

14 Commits

Author SHA1 Message Date
f6f0e10935 Fix Url API Route 2026-03-12 12:11:10 +08:00
2108f403aa Update .env.example to use relative URL '/' as default for NEXT_PUBLIC_BASE_URL
This ensures the API uses the same protocol and domain as the frontend,
preventing mixed content blocking in staging/production environments.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-12 12:06:16 +08:00
c6c3eebadf Fix: CORS and API base URL for music create in staging
- Update CORS config to allow all origins (wildcard first) for better staging support
- Change API fetch base URL from absolute to relative (/) to prevent mixed content blocking
- Add detailed logging in music create page for better debugging
- Update .env.example with better NEXT_PUBLIC_BASE_URL documentation
- Add MUSIK_CREATE_ANALYSIS.md with comprehensive error analysis

Fixes ERR_BLOCKED_BY_CLIENT error when creating music in staging environment

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-12 11:27:51 +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
16 changed files with 549 additions and 251 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

41
.env.example Normal file
View File

@@ -0,0 +1,41 @@
# 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
# IMPORTANT: For staging/production, set this to your actual domain
# Local development: NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Staging: NEXT_PUBLIC_BASE_URL=https://desa-darmasaba-stg.wibudev.com
# Production: NEXT_PUBLIC_BASE_URL=https://your-production-domain.com
# Or use relative URL '/' for automatic protocol/domain detection (recommended)
NEXT_PUBLIC_BASE_URL=/
# 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*
# env
.env*
# env local files (keep .env.example)
.env.local
.env*.local
.env.production
.env.development
!.env.example
# QC
QC
@@ -52,7 +57,5 @@ next-env.d.ts
.github/
.env.*
*.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"]

173
MUSIK_CREATE_ANALYSIS.md Normal file
View File

@@ -0,0 +1,173 @@
# Musik Desa - Create Feature Analysis
## Error Summary
**Error**: `ERR_BLOCKED_BY_CLIENT` saat create musik di staging environment
## Root Cause Analysis
### 1. **CORS Configuration Issue** (Primary)
File: `src/app/api/[[...slugs]]/route.ts`
The CORS configuration has specific origins listed:
```typescript
const corsConfig = {
origin: [
"http://localhost:3000",
"http://localhost:3001",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"*",
],
// ...
}
```
**Problem**: The wildcard `*` is at the end, but some browsers don't respect it when `credentials: true` is set.
### 2. **API Fetch Base URL** (Secondary)
File: `src/lib/api-fetch.ts`
```typescript
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
```
**Problem**:
- In staging, this might still default to `http://localhost:3000`
- Mixed content (HTTPS frontend → HTTP API) gets blocked by browsers
- The `NEXT_PUBLIC_BASE_URL` environment variable might not be set in staging
### 3. **File Storage Upload Path** (Tertiary)
File: `src/app/api/[[...slugs]]/_lib/fileStorage/_lib/create.ts`
```typescript
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
```
**Problem**: If `WIBU_UPLOAD_DIR` is not set or points to a non-writable location, uploads will fail silently.
## Solution
### Fix 1: Update CORS Configuration
**File**: `src/app/api/[[...slugs]]/route.ts`
```typescript
// Move wildcard to first position and ensure it works with credentials
const corsConfig = {
origin: [
"*", // Allow all origins (for staging flexibility)
"http://localhost:3000",
"http://localhost:3001",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"https://desa-darmasaba-stg.wibudev.com"
],
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
allowedHeaders: ["Content-Type", "Authorization", "Accept"],
exposedHeaders: ["Content-Range", "X-Content-Range"],
maxAge: 86400, // 24 hours
credentials: true,
};
```
### Fix 2: Add Environment Variable Validation
**File**: `.env.example` (update)
```bash
# Application Configuration
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# For staging/production, set this to your actual domain
# NEXT_PUBLIC_BASE_URL=https://cld-dkr-desa-darmasaba-stg.wibudev.com
```
### Fix 3: Update API Fetch to Handle Relative URLs
**File**: `src/lib/api-fetch.ts`
```typescript
import { AppServer } from '@/app/api/[[...slugs]]/route'
import { treaty } from '@elysiajs/eden'
// Use relative URL for better deployment flexibility
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
const ApiFetch = treaty<AppServer>(BASE_URL)
export default ApiFetch
```
### Fix 4: Add Error Handling in Create Page
**File**: `src/app/admin/(dashboard)/musik/create/page.tsx`
Add better error logging to diagnose issues:
```typescript
const handleSubmit = async () => {
// ... validation ...
try {
setIsSubmitting(true);
// Upload cover image
const coverRes = await ApiFetch.api.fileStorage.create.post({
file: coverFile,
name: coverFile.name,
});
if (!coverRes.data?.data?.id) {
console.error('Cover upload failed:', coverRes);
return toast.error('Gagal mengunggah cover, silakan coba lagi');
}
// ... rest of the code ...
} catch (error) {
console.error('Error creating musik:', {
error,
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
});
toast.error('Terjadi kesalahan saat membuat musik');
} finally {
setIsSubmitting(false);
}
};
```
## Testing Checklist
### Local Development
- [ ] Test create musik with cover image and audio file
- [ ] Verify CORS headers in browser DevTools Network tab
- [ ] Check that file uploads are saved to correct directory
### Staging Environment
- [ ] Set `NEXT_PUBLIC_BASE_URL` to staging domain
- [ ] Verify HTTPS is used for all API calls
- [ ] Check browser console for mixed content warnings
- [ ] Verify `WIBU_UPLOAD_DIR` is set and writable
- [ ] Test create musik end-to-end
## Additional Notes
### ERR_BLOCKED_BY_CLIENT Common Causes:
1. **CORS policy blocking** - Most likely cause
2. **Ad blockers** - Can block certain API endpoints
3. **Mixed content** - HTTPS page making HTTP requests
4. **Content Security Policy (CSP)** - Restrictive CSP headers
5. **Browser extensions** - Privacy/security extensions blocking requests
### Debugging Steps:
1. Open browser DevTools → Network tab
2. Try to create musik
3. Look for failed requests (red status)
4. Check the "Headers" tab for:
- Request URL (should be correct domain)
- Response headers (should have `Access-Control-Allow-Origin`)
- Status code (4xx/5xx indicates server-side issue)
5. Check browser console for CORS errors
## Recommended Next Steps
1. **Immediate**: Update CORS configuration to allow staging domain
2. **Short-term**: Add proper environment variable validation
3. **Long-term**: Implement proper error boundaries and logging

BIN
bun.lockb

Binary file not shown.

View File

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

View File

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

View File

@@ -123,37 +123,51 @@ export default function CreateMusik() {
setIsSubmitting(true);
// Upload cover image
console.log('Uploading cover image:', coverFile.name);
const coverRes = await ApiFetch.api.fileStorage.create.post({
file: coverFile,
name: coverFile.name,
});
console.log('Cover upload response:', coverRes);
const coverUploaded = coverRes.data?.data;
if (!coverUploaded?.id) {
return toast.error('Gagal mengunggah cover, silakan coba lagi');
console.error('Cover upload failed:', coverRes);
toast.error('Gagal mengunggah cover, silakan coba lagi');
return;
}
musikState.musik.create.form.coverImageId = coverUploaded.id;
// Upload audio file
console.log('Uploading audio file:', audioFile.name);
const audioRes = await ApiFetch.api.fileStorage.create.post({
file: audioFile,
name: audioFile.name,
});
console.log('Audio upload response:', audioRes);
const audioUploaded = audioRes.data?.data;
if (!audioUploaded?.id) {
return toast.error('Gagal mengunggah audio, silakan coba lagi');
console.error('Audio upload failed:', audioRes);
toast.error('Gagal mengunggah audio, silakan coba lagi');
return;
}
musikState.musik.create.form.audioFileId = audioUploaded.id;
// Create musik entry
console.log('Creating musik entry with form:', musikState.musik.create.form);
await musikState.musik.create.create();
resetForm();
router.push('/admin/musik');
} catch (error) {
console.error('Error creating musik:', error);
console.error('Error creating musik:', {
error,
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
});
toast.error('Terjadi kesalahan saat membuat musik');
} finally {
setIsSubmitting(false);

View File

@@ -47,15 +47,16 @@ fs.mkdir(UPLOAD_DIR_IMAGE, {
const corsConfig = {
origin: [
"*", // Allow all origins - must be first when using credentials: true
"http://localhost:3000",
"http://localhost:3001",
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
"*", // Allow all origins in development
"https://desa-darmasaba-stg.wibudev.com",
],
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
allowedHeaders: ["Content-Type", "Authorization", "*"],
exposedHeaders: "*",
allowedHeaders: ["Content-Type", "Authorization", "Accept", "*"],
exposedHeaders: ["Content-Range", "X-Content-Range", "*"],
maxAge: 86400, // 24 hours
credentials: true,
};

View File

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

View File

@@ -93,19 +93,28 @@ export default function FixedPlayerBar() {
mt="md"
style={{
position: 'fixed',
top: '50%',
top: '50%', // Menempatkan titik atas ikon di tengah layar
left: '0px',
transform: 'translateY(-50%)',
transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
borderBottomRightRadius: '20px',
borderTopRightRadius: '20px',
cursor: 'pointer',
transition: 'transform 0.2s ease',
zIndex: 1000 // Higher z-index
zIndex: 1
}}
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>
{/* Spacer to prevent content from being hidden behind player */}
<Box h={20} />
</>
);
}
@@ -122,125 +131,132 @@ export default function FixedPlayerBar() {
bottom={0}
left={0}
right={0}
p={{ base: 'xs', sm: 'sm' }}
shadow="xl"
p="sm"
shadow="lg"
style={{
zIndex: 1000,
zIndex: 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 */}
<Group gap="xs" flex={{ base: 2, sm: 1 }} style={{ minWidth: 0 }} wrap="nowrap">
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
<Avatar
src={currentSong.coverImage?.link || ''}
alt={currentSong.judul}
size={"36"}
size={40}
radius="sm"
imageProps={{ loading: 'lazy' }}
/>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz={{ base: 'xs', sm: 'sm' }} fw={600} truncate>
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={600} truncate>
{currentSong.judul}
</Text>
<Text fz="10px" c="dimmed" truncate>
<Text fz="xs" c="dimmed" truncate>
{currentSong.artis}
</Text>
</Box>
</Group>
{/* Controls - Center */}
<Group gap={"xs"} flex={{ base: 1, sm: 2 }} justify="center" wrap="nowrap">
{/* Shuffle - Desktop Only */}
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color={isShuffle ? '#0B4F78' : 'gray'}
size={"md"}
onClick={handleToggleShuffle}
visibleFrom="sm"
>
<IconArrowsShuffle size={18} />
</ActionIcon>
{/* Controls + Progress - Center */}
<Group gap="xs" flex={2} justify="center">
{/* Control Buttons */}
<Group gap="xs">
<ActionIcon
variant={isShuffle ? 'filled' : 'subtle'}
color={isShuffle ? 'blue' : 'gray'}
size="lg"
onClick={handleToggleShuffle}
title="Shuffle"
>
<IconArrowsShuffle size={18} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={playPrev}
>
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={playPrev}
title="Previous"
>
<IconPlayerSkipBackFilled size={20} />
</ActionIcon>
<ActionIcon
variant="filled"
color="#0B4F78"
size={"lg"}
radius="xl"
onClick={togglePlayPause}
>
{isPlaying ? (
<IconPlayerPauseFilled size={24} />
) : (
<IconPlayerPlayFilled size={24} />
)}
</ActionIcon>
<ActionIcon
variant="filled"
color={isPlaying ? 'blue' : 'gray'}
size="xl"
radius="xl"
onClick={togglePlayPause}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<IconPlayerPauseFilled size={24} />
) : (
<IconPlayerPlayFilled size={24} />
)}
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
onClick={playNext}
>
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={playNext}
title="Next"
>
<IconPlayerSkipForwardFilled size={20} />
</ActionIcon>
{/* Repeat - Desktop Only */}
<ActionIcon
variant={isRepeat ? 'filled' : 'subtle'}
color={isRepeat ? '#0B4F78' : 'gray'}
size={"md"}
onClick={toggleRepeat}
visibleFrom="sm"
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
<ActionIcon
variant="subtle"
color={isRepeat ? 'blue' : 'gray'}
size="lg"
onClick={toggleRepeat}
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
>
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
</ActionIcon>
</Group>
{/* Progress Bar - Desktop Only */}
<Box w={150} ml="md" visibleFrom="md">
{/* Progress Bar - Desktop */}
<Box w={200} display={{ base: 'none', md: 'block' }}>
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="xs"
color="#0B4F78"
size="sm"
color="blue"
label={(value) => formatTime(value)}
/>
</Box>
</Group>
{/* Right Controls - Volume + Close */}
<Group gap={4} flex={1} justify="flex-end" wrap="nowrap">
{/* Volume Control - Tablet/Desktop */}
<Group gap="xs" flex={1} justify="flex-end">
<Box
onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)}
pos="relative"
visibleFrom="sm"
>
<ActionIcon
variant="subtle"
color={isMuted ? 'red' : 'gray'}
size="lg"
onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? <IconVolumeOff size={18} /> : <IconVolume size={18} />}
{isMuted ? (
<IconVolumeOff size={18} />
) : (
<IconVolume size={18} />
)}
</ActionIcon>
<Transition
mounted={showVolume}
transition="scale-y"
duration={200}
timingFunction="ease"
>
{(style) => (
<Paper
@@ -249,8 +265,8 @@ export default function FixedPlayerBar() {
position: 'absolute',
bottom: '100%',
right: 0,
marginBottom: '10px',
padding: '10px',
mb: 'xs',
p: 'sm',
zIndex: 1001,
}}
shadow="md"
@@ -260,8 +276,8 @@ export default function FixedPlayerBar() {
value={isMuted ? 0 : volume}
max={100}
onChange={handleVolumeChange}
h={80}
color="#0B4F78"
h={100}
color="blue"
size="sm"
/>
</Paper>
@@ -272,29 +288,30 @@ export default function FixedPlayerBar() {
<ActionIcon
variant="subtle"
color="gray"
size={"md"}
size="lg"
onClick={handleMinimizePlayer}
title="Minimize player"
>
<IconX size={18} />
</ActionIcon>
</Group>
</Flex>
{/* Progress Bar - Mobile (Base) */}
<Box px="xs" mt={4} hiddenFrom="md">
{/* Progress Bar - Mobile */}
<Box mt="xs" display={{ base: 'block', md: 'none' }}>
<Slider
value={currentTime}
max={duration || 100}
onChange={handleSeek}
size="xs"
color="#0B4F78"
size="sm"
color="blue"
label={(value) => formatTime(value)}
/>
</Box>
</Paper>
{/* 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 */
import { Paper, Table, Title, Text } from '@mantine/core';
import { Paper, Table, Title } from '@mantine/core';
function Section({ title, data }: any) {
if (!data || data.length === 0) return null;
return (
<>
<Table.Tr bg="gray.0">
<Table.Tr>
<Table.Td colSpan={2}>
<Text fw={700} fz={{ base: 'xs', sm: 'sm' }}>{title}</Text>
<strong>{title}</strong>
</Table.Td>
</Table.Tr>
{data.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td>
<Text fz={{ base: 'xs', sm: 'sm' }} lineClamp={2}>
{item.kode} - {item.uraian}
</Text>
{item.kode} - {item.uraian}
</Table.Td>
<Table.Td ta="right">
<Text fz={{ base: 'xs', sm: 'sm' }} fw={500} style={{ whiteSpace: 'nowrap' }}>
Rp {item.anggaran.toLocaleString('id-ID')}
</Text>
Rp {item.anggaran.toLocaleString('id-ID')}
</Table.Td>
</Table.Tr>
))}
@@ -43,24 +39,22 @@ export default function PaguTable({ apbdesData }: any) {
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
return (
<Paper withBorder p={{ base: 'sm', sm: 'md' }} radius="md">
<Title order={5} mb="md" fz={{ base: 'sm', sm: 'md' }}>{title}</Title>
<Paper withBorder p="md" radius="md">
<Title order={5} mb="md">{title}</Title>
<Table.ScrollContainer minWidth={280}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th fz={{ base: 'xs', sm: 'sm' }}>Uraian</Table.Th>
<Table.Th ta="right" fz={{ base: 'xs', sm: 'sm' }}>Anggaran (Rp)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="2) BELANJA" data={belanja} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Uraian</Table.Th>
<Table.Th ta="right">Anggaran (Rp)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Section title="1) PENDAPATAN" data={pendapatan} />
<Section title="2) BELANJA" data={belanja} />
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
</Table.Tbody>
</Table>
</Paper>
);
}

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import { AppServer } from '@/app/api/[[...slugs]]/route'
import { treaty } from '@elysiajs/eden'
// const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'localhost:3000'
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
// Use relative URL '/' for better deployment flexibility
// This allows the API to work correctly in both development and staging/production
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
const ApiFetch = treaty<AppServer>(BASE_URL)