Compare commits
44 Commits
dd09d7c90a
...
tasks/add-
| Author | SHA1 | Date | |
|---|---|---|---|
| 34a37dc63b | |||
| 0e6f7e1769 | |||
| feb853d06e | |||
| 3de412afe0 | |||
| 87d234e57f | |||
| fd18a22834 | |||
| 3e8b961e52 | |||
| 82d779e5e0 | |||
| a6517166cb | |||
| 483b6be677 | |||
| f8dad0dbcd | |||
| 74301fe074 | |||
| 8b19abc628 | |||
| e73798a0f2 | |||
| 1a91f3c9ad | |||
| 9b74592101 | |||
| 55f4b94082 | |||
| b403bc754c | |||
| 67b87f145e | |||
| 59ae8ad039 | |||
| c012d5778c | |||
| af31bd8aef | |||
| 721357adcf | |||
| 39776ec355 | |||
| 50a7356618 | |||
| 4494dd98ef | |||
| 970949a68b | |||
| 42bcba6c96 | |||
| d1d54e5c25 | |||
| 0a4b85fd82 | |||
|
|
6064ef0759 | ||
| 1c00c326c9 | |||
| 4eba96140d | |||
| 4dfcf20322 | |||
|
|
6d26ace8ab | ||
|
|
f5566bca2c | ||
|
|
ba964df32c | ||
|
|
df3f382a97 | ||
|
|
7368a367f4 | ||
|
|
ed664d5b10 | ||
|
|
0ba30aa5b2 | ||
|
|
790d6535e5 | ||
|
|
46ce16ae97 | ||
| d66a952d4c |
47
.dockerignore
Normal file
47
.dockerignore
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
bun-debug.log*
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Markdown/Documentation
|
||||||
|
README.md
|
||||||
|
GEMINI.md
|
||||||
|
AGENTS.md
|
||||||
|
AUDIT_REPORT.md
|
||||||
|
QWEN.md
|
||||||
|
NOTE.md
|
||||||
|
task-project-apbdes.md
|
||||||
|
MUSIK_CREATE_ANALYSIS.md
|
||||||
|
darkMode.md
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
/tmp_assets
|
||||||
|
/foldergambar
|
||||||
|
/googleapi
|
||||||
|
/xx
|
||||||
|
/xx.ts
|
||||||
|
/xx.txt
|
||||||
|
/test.txt
|
||||||
|
/x.json
|
||||||
|
/x.sh
|
||||||
|
/xcoba.ts
|
||||||
|
/xcoba2.ts
|
||||||
|
/gambar.ttx
|
||||||
|
/test-berita-state.ts
|
||||||
19
.env
19
.env
@@ -1,19 +0,0 @@
|
|||||||
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=8479423145:AAE9ArrOgTD3DyVxYSVs3IXN40u_sL6c9sw
|
|
||||||
CHAT_ID=-1003368982298
|
|
||||||
@@ -11,6 +11,9 @@ SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
|
|||||||
WIBU_UPLOAD_DIR=uploads
|
WIBU_UPLOAD_DIR=uploads
|
||||||
WIBU_DOWNLOAD_DIR=./download
|
WIBU_DOWNLOAD_DIR=./download
|
||||||
|
|
||||||
|
# WhatsApp Server Configuration
|
||||||
|
WA_SERVER_TOKEN=your_whatsapp_server_token
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
# IMPORTANT: For staging/production, set this to your actual domain
|
# IMPORTANT: For staging/production, set this to your actual domain
|
||||||
# Local development: NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
# Local development: NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|||||||
219
.github/workflows/build.yml
vendored
219
.github/workflows/build.yml
vendored
@@ -1,219 +0,0 @@
|
|||||||
name: Build And Save Log
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
environment:
|
|
||||||
description: "Target environment (e.g., staging, production)"
|
|
||||||
required: true
|
|
||||||
default: "staging"
|
|
||||||
version:
|
|
||||||
description: "Version to deploy"
|
|
||||||
required: false
|
|
||||||
default: "latest"
|
|
||||||
|
|
||||||
env:
|
|
||||||
APP_NAME: desa-darmasaba-action
|
|
||||||
WA_PHONE: "6289697338821,6289697338822"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:14
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
|
||||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
|
||||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd="pg_isready"
|
|
||||||
--health-interval=10s
|
|
||||||
--health-timeout=5s
|
|
||||||
--health-retries=5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Checkout kode sumber
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
# Setup Bun
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
# Cache dependencies
|
|
||||||
- name: Cache dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: .bun
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
# Step 1: Set BRANCH_NAME based on event type
|
|
||||||
- name: Set BRANCH_NAME
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
|
||||||
echo "BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 2: Generate APP_VERSION dynamically
|
|
||||||
- name: Set APP_VERSION
|
|
||||||
run: echo "APP_VERSION=${{ github.sha }}---$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Step 3: Kirim notifikasi ke API build Start
|
|
||||||
- name: Notify start build
|
|
||||||
run: |
|
|
||||||
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
|
|
||||||
for PHONE in "${PHONES[@]}"; do
|
|
||||||
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:start\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}'))")
|
|
||||||
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
# Konfigurasi environment variable untuk PostgreSQL dan variabel tambahan
|
|
||||||
- name: Set up environment variables
|
|
||||||
run: |
|
|
||||||
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
|
|
||||||
echo "PORT=3000" >> .env
|
|
||||||
echo "NEXT_PUBLIC_WIBU_URL=localhost:3000" >> .env
|
|
||||||
echo "WIBU_UPLOAD_DIR=/uploads" >> .env
|
|
||||||
|
|
||||||
# Create log file
|
|
||||||
- name: Create log file
|
|
||||||
run: touch build.txt
|
|
||||||
|
|
||||||
# Migrasi database menggunakan Prisma
|
|
||||||
- name: Apply Prisma schema to database
|
|
||||||
run: bun prisma db push >> build.txt 2>&1
|
|
||||||
|
|
||||||
# Seed database (opsional)
|
|
||||||
- name: Seed database
|
|
||||||
run: |
|
|
||||||
bun prisma db seed >> build.txt 2>&1 || echo "Seed failed or no seed data found. Continuing without seed." >> build.txt
|
|
||||||
|
|
||||||
# Build project
|
|
||||||
- name: Build project
|
|
||||||
run: bun run build >> build.txt 2>&1
|
|
||||||
|
|
||||||
# Ensure project directory exists
|
|
||||||
- name: Ensure /var/www/projects/${{ env.APP_NAME }} exists
|
|
||||||
uses: appleboy/ssh-action@master
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.VPS_HOST }}
|
|
||||||
username: ${{ secrets.VPS_USERNAME }}
|
|
||||||
key: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
mkdir -p /var/www/projects/${{ env.APP_NAME }}
|
|
||||||
|
|
||||||
# Deploy to a new version directory
|
|
||||||
- name: Deploy to VPS (New Version)
|
|
||||||
uses: appleboy/scp-action@master
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.VPS_HOST }}
|
|
||||||
username: ${{ secrets.VPS_USERNAME }}
|
|
||||||
key: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
source: "."
|
|
||||||
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}"
|
|
||||||
|
|
||||||
# Set up environment variables
|
|
||||||
- name: Set up environment variables
|
|
||||||
run: |
|
|
||||||
rm -r .env
|
|
||||||
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5433/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
|
|
||||||
echo "NEXT_PUBLIC_WIBU_URL=${{ env.APP_NAME }}" >> .env
|
|
||||||
echo "WIBU_UPLOAD_DIR=/var/www/projects/${{ env.APP_NAME }}/uploads" >> .env
|
|
||||||
|
|
||||||
# Kirim file .env ke server
|
|
||||||
- name: Upload .env to server
|
|
||||||
uses: appleboy/scp-action@master
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.VPS_HOST }}
|
|
||||||
username: ${{ secrets.VPS_USERNAME }}
|
|
||||||
key: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
source: ".env"
|
|
||||||
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}/"
|
|
||||||
|
|
||||||
# manage deployment
|
|
||||||
- name: manage deployment
|
|
||||||
uses: appleboy/ssh-action@master
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.VPS_HOST }}
|
|
||||||
username: ${{ secrets.VPS_USERNAME }}
|
|
||||||
key: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
|
|
||||||
# Source ~/.bashrc
|
|
||||||
source ~/.bashrc
|
|
||||||
|
|
||||||
# Find an available port
|
|
||||||
PORT=$(curl -s -X GET https://wibu-bot.wibudev.com/api/find-port | jq -r '.[0]')
|
|
||||||
if [ -z "$PORT" ] || ! [[ "$PORT" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo "Invalid or missing port from API."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# manage deployment
|
|
||||||
cd /var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}
|
|
||||||
|
|
||||||
# Create uploads directory
|
|
||||||
mkdir -p /var/www/projects/${{ env.APP_NAME }}/uploads
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
bun install --production
|
|
||||||
|
|
||||||
# Apply database schema
|
|
||||||
if ! bun prisma db push; then
|
|
||||||
echo "Database migration failed."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Seed database (optional)
|
|
||||||
bun prisma db seed || echo "tidak membutuhkan seed"
|
|
||||||
|
|
||||||
# Restart the application
|
|
||||||
pm2 reload ${{ env.APP_NAME }} || pm2 start "bun run start --port $PORT" --name "${{ env.APP_NAME }}-$PORT" --namespace "${{ env.APP_NAME }}"
|
|
||||||
|
|
||||||
# Step 4: Set BUILD_STATUS based on success or failure
|
|
||||||
- name: Set BUILD_STATUS
|
|
||||||
if: success()
|
|
||||||
run: echo "BUILD_STATUS=success" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Set BUILD_STATUS on failure
|
|
||||||
if: failure()
|
|
||||||
run: echo "BUILD_STATUS=failed" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Update status log
|
|
||||||
- name: Update status log
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "=====================" >> build.txt
|
|
||||||
echo "BUILD_STATUS=${{ env.BUILD_STATUS }}" >> build.txt
|
|
||||||
echo "APP_NAME=${{ env.APP_NAME }}" >> build.txt
|
|
||||||
echo "APP_VERSION=${{ env.APP_VERSION }}" >> build.txt
|
|
||||||
echo "=====================" >> build.txt
|
|
||||||
|
|
||||||
# Upload log to 0x0.st
|
|
||||||
- name: Upload log to 0x0.st
|
|
||||||
id: upload_log
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
LOG_URL=$(curl -F "file=@build.txt" https://wibu-bot.wibudev.com/api/file )
|
|
||||||
echo "LOG_URL=$LOG_URL" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Kirim notifikasi ke API
|
|
||||||
- name: Notify build success via API
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
|
|
||||||
for PHONE in "${PHONES[@]}"; do
|
|
||||||
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:${{ env.BUILD_STATUS }}\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}\nLog:${{ env.LOG_URL }}'))")
|
|
||||||
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
|
|
||||||
done
|
|
||||||
56
.github/workflows/docker-publish.yml
vendored
Normal file
56
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: Publish Docker to GHCR
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build & Push to GHCR
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Free disk space
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /usr/local/lib/android
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||||
|
sudo docker image prune --all --force
|
||||||
|
df -h
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract tag name
|
||||||
|
id: meta
|
||||||
|
run: echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64
|
||||||
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
106
.github/workflows/publish.yml
vendored
Normal file
106
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
name: Publish Docker to GHCR
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
stack_env:
|
||||||
|
description: "stack env"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
default: "dev"
|
||||||
|
options:
|
||||||
|
- dev
|
||||||
|
- prod
|
||||||
|
- stg
|
||||||
|
tag:
|
||||||
|
description: "Image tag (e.g. 1.0.0)"
|
||||||
|
required: true
|
||||||
|
default: "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build & Push to GHCR ${{ github.repository }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: ${{ vars.PORTAINER_ENV || 'portainer' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Free disk space
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /usr/local/lib/android
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||||
|
sudo docker image prune --all --force
|
||||||
|
df -h
|
||||||
|
|
||||||
|
- name: Checkout branch ${{ github.event.inputs.stack_env }}
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.stack_env }}
|
||||||
|
|
||||||
|
- name: Checkout scripts from main
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
path: .ci
|
||||||
|
sparse-checkout: .github/workflows/script
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Generate image metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
|
||||||
|
type=raw,value=${{ github.event.inputs.stack_env }}-latest
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
no-cache: true
|
||||||
|
|
||||||
|
- name: Notify success
|
||||||
|
if: success()
|
||||||
|
run: bash ./.ci/.github/workflows/script/notify.sh
|
||||||
|
env:
|
||||||
|
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
NOTIFY_STATUS: success
|
||||||
|
NOTIFY_WORKFLOW: "Publish Docker"
|
||||||
|
NOTIFY_DETAIL: "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}"
|
||||||
|
|
||||||
|
- name: Notify failure
|
||||||
|
if: failure()
|
||||||
|
run: bash ./.ci/.github/workflows/script/notify.sh
|
||||||
|
env:
|
||||||
|
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
NOTIFY_STATUS: failure
|
||||||
|
NOTIFY_WORKFLOW: "Publish Docker"
|
||||||
|
NOTIFY_DETAIL: "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}"
|
||||||
60
.github/workflows/re-pull.yml
vendored
Normal file
60
.github/workflows/re-pull.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Re-Pull Docker
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
stack_name:
|
||||||
|
description: "stack name"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
stack_env:
|
||||||
|
description: "stack env"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
default: "dev"
|
||||||
|
options:
|
||||||
|
- dev
|
||||||
|
- stg
|
||||||
|
- prod
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Re-Pull Docker ${{ github.event.inputs.stack_name }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: ${{ vars.PORTAINER_ENV || 'portainer' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout scripts from main
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
sparse-checkout: .github/workflows/script
|
||||||
|
|
||||||
|
- name: Deploy ke Portainer
|
||||||
|
run: bash ./.github/workflows/script/re-pull.sh
|
||||||
|
env:
|
||||||
|
PORTAINER_USERNAME: ${{ secrets.PORTAINER_USERNAME }}
|
||||||
|
PORTAINER_PASSWORD: ${{ secrets.PORTAINER_PASSWORD }}
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||||
|
STACK_NAME: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}
|
||||||
|
|
||||||
|
- name: Notify success
|
||||||
|
if: success()
|
||||||
|
run: bash ./.github/workflows/script/notify.sh
|
||||||
|
env:
|
||||||
|
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
NOTIFY_STATUS: success
|
||||||
|
NOTIFY_WORKFLOW: "Re-Pull Docker"
|
||||||
|
NOTIFY_DETAIL: "Stack: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}"
|
||||||
|
|
||||||
|
- name: Notify failure
|
||||||
|
if: failure()
|
||||||
|
run: bash ./.github/workflows/script/notify.sh
|
||||||
|
env:
|
||||||
|
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
NOTIFY_STATUS: failure
|
||||||
|
NOTIFY_WORKFLOW: "Re-Pull Docker"
|
||||||
|
NOTIFY_DETAIL: "Stack: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}"
|
||||||
26
.github/workflows/script/notify.sh
vendored
Normal file
26
.github/workflows/script/notify.sh
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
: "${TELEGRAM_TOKEN:?TELEGRAM_TOKEN tidak di-set}"
|
||||||
|
: "${TELEGRAM_CHAT_ID:?TELEGRAM_CHAT_ID tidak di-set}"
|
||||||
|
: "${NOTIFY_STATUS:?NOTIFY_STATUS tidak di-set}"
|
||||||
|
: "${NOTIFY_WORKFLOW:?NOTIFY_WORKFLOW tidak di-set}"
|
||||||
|
|
||||||
|
if [ "$NOTIFY_STATUS" = "success" ]; then
|
||||||
|
ICON="✅"
|
||||||
|
TEXT="${ICON} *${NOTIFY_WORKFLOW}* berhasil!"
|
||||||
|
else
|
||||||
|
ICON="❌"
|
||||||
|
TEXT="${ICON} *${NOTIFY_WORKFLOW}* gagal!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$NOTIFY_DETAIL" ]; then
|
||||||
|
TEXT="${TEXT}
|
||||||
|
${NOTIFY_DETAIL}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||||
|
--arg text "$TEXT" \
|
||||||
|
'{chat_id: $chat_id, text: $text, parse_mode: "Markdown"}')"
|
||||||
120
.github/workflows/script/re-pull.sh
vendored
Normal file
120
.github/workflows/script/re-pull.sh
vendored
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
: "${PORTAINER_URL:?PORTAINER_URL tidak di-set}"
|
||||||
|
: "${PORTAINER_USERNAME:?PORTAINER_USERNAME tidak di-set}"
|
||||||
|
: "${PORTAINER_PASSWORD:?PORTAINER_PASSWORD tidak di-set}"
|
||||||
|
: "${STACK_NAME:?STACK_NAME tidak di-set}"
|
||||||
|
|
||||||
|
# Timeout total: MAX_RETRY * SLEEP_INTERVAL detik
|
||||||
|
MAX_RETRY=60 # 60 × 10s = 10 menit
|
||||||
|
SLEEP_INTERVAL=10
|
||||||
|
|
||||||
|
echo "🔐 Autentikasi ke Portainer..."
|
||||||
|
TOKEN=$(curl -s -X POST "https://${PORTAINER_URL}/api/auth" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\": \"${PORTAINER_USERNAME}\", \"password\": \"${PORTAINER_PASSWORD}\"}" \
|
||||||
|
| jq -r .jwt)
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||||
|
echo "❌ Autentikasi gagal! Cek PORTAINER_URL, USERNAME, dan PASSWORD."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔍 Mencari stack: $STACK_NAME..."
|
||||||
|
STACK=$(curl -s -X GET "https://${PORTAINER_URL}/api/stacks" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
| jq ".[] | select(.Name == \"$STACK_NAME\")")
|
||||||
|
|
||||||
|
if [ -z "$STACK" ]; then
|
||||||
|
echo "❌ Stack '$STACK_NAME' tidak ditemukan di Portainer!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STACK_ID=$(echo "$STACK" | jq -r .Id)
|
||||||
|
ENDPOINT_ID=$(echo "$STACK" | jq -r .EndpointId)
|
||||||
|
ENV=$(echo "$STACK" | jq '.Env // []')
|
||||||
|
|
||||||
|
# ── Catat container ID lama sebelum redeploy ──────────────────────────────────
|
||||||
|
echo "📸 Mencatat container aktif sebelum redeploy..."
|
||||||
|
CONTAINERS_BEFORE=$(curl -s -X GET \
|
||||||
|
"https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true&filters=%7B%22label%22%3A%5B%22com.docker.compose.project%3D${STACK_NAME}%22%5D%7D" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}")
|
||||||
|
|
||||||
|
OLD_IDS=$(echo "$CONTAINERS_BEFORE" | jq -r '[.[] | .Id] | join(",")')
|
||||||
|
echo " Container lama: $(echo "$CONTAINERS_BEFORE" | jq -r '[.[] | .Names[0]] | join(", ")')"
|
||||||
|
|
||||||
|
# ── Ambil compose file lalu trigger redeploy ─────────────────────────────────
|
||||||
|
echo "📄 Mengambil compose file..."
|
||||||
|
STACK_FILE=$(curl -s -X GET "https://${PORTAINER_URL}/api/stacks/${STACK_ID}/file" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
| jq -r .StackFileContent)
|
||||||
|
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg content "$STACK_FILE" \
|
||||||
|
--argjson env "$ENV" \
|
||||||
|
'{stackFileContent: $content, env: $env, pullImage: true}')
|
||||||
|
|
||||||
|
echo "🚀 Triggering redeploy $STACK_NAME (pull latest image)..."
|
||||||
|
HTTP_STATUS=$(curl -s -o /tmp/portainer_response.json -w "%{http_code}" \
|
||||||
|
-X PUT "https://${PORTAINER_URL}/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD")
|
||||||
|
|
||||||
|
if [ "$HTTP_STATUS" != "200" ]; then
|
||||||
|
echo "❌ Redeploy gagal! HTTP Status: $HTTP_STATUS"
|
||||||
|
cat /tmp/portainer_response.json | jq .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "⏳ Menunggu image selesai di-pull dan container baru running..."
|
||||||
|
echo " (Timeout: $((MAX_RETRY * SLEEP_INTERVAL)) detik)"
|
||||||
|
|
||||||
|
COUNT=0
|
||||||
|
while [ $COUNT -lt $MAX_RETRY ]; do
|
||||||
|
sleep $SLEEP_INTERVAL
|
||||||
|
COUNT=$((COUNT + 1))
|
||||||
|
|
||||||
|
CONTAINERS=$(curl -s -X GET \
|
||||||
|
"https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true&filters=%7B%22label%22%3A%5B%22com.docker.compose.project%3D${STACK_NAME}%22%5D%7D" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}")
|
||||||
|
|
||||||
|
# Container baru = ID tidak ada di daftar container lama
|
||||||
|
NEW_RUNNING=$(echo "$CONTAINERS" | jq \
|
||||||
|
--arg old "$OLD_IDS" \
|
||||||
|
'[.[] | select(.State == "running" and ((.Id) as $id | ($old | split(",") | index($id)) == null))] | length')
|
||||||
|
|
||||||
|
FAILED=$(echo "$CONTAINERS" | jq \
|
||||||
|
'[.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not) and (.Names[0] | test("seed") | not))] | length')
|
||||||
|
|
||||||
|
echo "🔄 [$((COUNT * SLEEP_INTERVAL))s / $((MAX_RETRY * SLEEP_INTERVAL))s] Container baru running: ${NEW_RUNNING} | Gagal: ${FAILED}"
|
||||||
|
echo "$CONTAINERS" | jq -r '.[] | " → \(.Names[0]) | \(.State) | \(.Status) | id: \(.Id[:12])"'
|
||||||
|
|
||||||
|
if [ "$FAILED" -gt "0" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ Ada container yang crash!"
|
||||||
|
echo "$CONTAINERS" | jq -r '.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not) and (.Names[0] | test("seed") | not)) | " → \(.Names[0]) | \(.Status)"'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEW_RUNNING" -gt "0" ]; then
|
||||||
|
# Cleanup dangling images setelah redeploy sukses
|
||||||
|
echo "🧹 Membersihkan dangling images..."
|
||||||
|
curl -s -X POST "https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/images/prune" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"filters":{"dangling":["true"]}}' | jq -r '" Reclaimed: \(.SpaceReclaimed // 0 | . / 1073741824 | tostring | .[0:5]) GB"'
|
||||||
|
|
||||||
|
echo "✅ Cleanup selesai!"
|
||||||
|
echo ""
|
||||||
|
echo "✅ Stack $STACK_NAME berhasil di-redeploy dengan image baru dan running!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "❌ Timeout $((MAX_RETRY * SLEEP_INTERVAL))s! Container baru tidak kunjung running."
|
||||||
|
echo " Kemungkinan image masih dalam proses pull atau ada error di server."
|
||||||
|
exit 1
|
||||||
55
.github/workflows/test.yml
vendored
55
.github/workflows/test.yml
vendored
@@ -1,55 +0,0 @@
|
|||||||
name: test workflows
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
environment:
|
|
||||||
description: "Target environment (e.g., staging, production)"
|
|
||||||
required: true
|
|
||||||
default: "staging"
|
|
||||||
version:
|
|
||||||
description: "Version to deploy"
|
|
||||||
required: false
|
|
||||||
default: "latest"
|
|
||||||
|
|
||||||
env:
|
|
||||||
APP_NAME: desa-darmasaba-action
|
|
||||||
WA_PHONE: "6289697338821,6289697338822"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# Checkout kode sumber
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
# Setup Bun
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
# Create log file
|
|
||||||
- name: Create log file
|
|
||||||
run: touch build.txt
|
|
||||||
|
|
||||||
# Step 1: Set BRANCH_NAME based on event type
|
|
||||||
- name: Set BRANCH_NAME
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
|
||||||
echo "BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 2: Generate APP_VERSION dynamically
|
|
||||||
- name: Set APP_VERSION
|
|
||||||
run: echo "APP_VERSION=${{ github.sha }}---$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Step 3: Kirim notifikasi ke API build Start
|
|
||||||
- name: Notify start build
|
|
||||||
run: |
|
|
||||||
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
|
|
||||||
for PHONE in "${PHONES[@]}"; do
|
|
||||||
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:start\nApp:${{ env.APP_NAME }}\nenv:${{ inputs.environment }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}'))")
|
|
||||||
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
|
|
||||||
done
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -30,10 +30,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env
|
# env
|
||||||
# env local files (keep .env.example)
|
# env local files (keep .env.example)
|
||||||
.env.local
|
.env*
|
||||||
.env*.local
|
|
||||||
.env.production
|
|
||||||
.env.development
|
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# QC
|
# QC
|
||||||
@@ -55,7 +52,6 @@ next-env.d.ts
|
|||||||
# cache
|
# cache
|
||||||
/cache
|
/cache
|
||||||
|
|
||||||
.github/
|
|
||||||
|
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
|||||||
13
.qwen/settings.json
Normal file
13
.qwen/settings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright-mcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"playwright-mcp@latest"
|
||||||
|
],
|
||||||
|
"timeout": 60000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$version": 3
|
||||||
|
}
|
||||||
9
.qwen/settings.json.orig
Normal file
9
.qwen/settings.json.orig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright-mcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "playwright-mcp@latest"],
|
||||||
|
"timeout": 60000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
DEV-INSPECTOR-ANALYSIS.md
Normal file
191
DEV-INSPECTOR-ANALYSIS.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Dev Inspector - Analisis & Rekomendasi untuk Project Desa Darmasaba
|
||||||
|
|
||||||
|
## 📋 Ringkasan Analisis
|
||||||
|
|
||||||
|
Dokumen `dev-inspector-click-to-source.md` **TIDAK dapat diterapkan langsung** ke project ini karena perbedaan arsitektur fundamental.
|
||||||
|
|
||||||
|
## 🔍 Perbedaan Arsitektur
|
||||||
|
|
||||||
|
| Syarat di Dokumen | Project Desa Darmasaba | Status |
|
||||||
|
|-------------------|------------------------|--------|
|
||||||
|
| **Vite sebagai bundler** | Next.js 15 (Webpack/Turbopack) | ❌ Tidak kompatibel |
|
||||||
|
| **Elysia + Vite middlewareMode** | Next.js App Router + Elysia sebagai API handler | ❌ Berbeda |
|
||||||
|
| **React** | ✅ React 19 | ✅ Kompatibel |
|
||||||
|
| **Bun runtime** | ✅ Bun | ✅ Kompatibel |
|
||||||
|
|
||||||
|
## ✅ Solusi: Next.js Sudah Punya Built-in Click-to-Source
|
||||||
|
|
||||||
|
Next.js memiliki fitur **click-to-source bawaan** yang bekerja tanpa setup tambahan:
|
||||||
|
|
||||||
|
### Cara Menggunakan
|
||||||
|
|
||||||
|
1. **Pastikan dalam development mode:**
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Klik elemen dengan modifier key:**
|
||||||
|
- **macOS**: `Option` + `Click` (atau `⌥` + `Click`)
|
||||||
|
- **Windows/Linux**: `Alt` + `Click`
|
||||||
|
|
||||||
|
3. **File akan terbuka di editor** pada baris dan kolom yang tepat
|
||||||
|
|
||||||
|
### Syarat Agar Berfungsi
|
||||||
|
|
||||||
|
1. **Editor harus ada di PATH**
|
||||||
|
|
||||||
|
VS Code biasanya sudah terdaftar. Jika menggunakan editor lain, set:
|
||||||
|
```bash
|
||||||
|
# Untuk Cursor
|
||||||
|
export EDITOR=cursor
|
||||||
|
|
||||||
|
# Untuk Windsurf
|
||||||
|
export EDITOR=windsurf
|
||||||
|
|
||||||
|
# Untuk Sublime Text
|
||||||
|
export EDITOR=subl
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Hanya berfungsi di development mode**
|
||||||
|
- Fitur ini otomatis tree-shaken di production
|
||||||
|
- Zero overhead di production build
|
||||||
|
|
||||||
|
3. **Browser DevTools harus terbuka** (beberapa browser memerlukan ini)
|
||||||
|
|
||||||
|
## 🎯 Rekomendasi untuk Project Ini
|
||||||
|
|
||||||
|
### Opsi 1: Gunakan Built-in Next.js (DIREKOMENDASIKAN)
|
||||||
|
|
||||||
|
**Kelebihan:**
|
||||||
|
- ✅ Zero setup
|
||||||
|
- ✅ Maintain oleh Vercel
|
||||||
|
- ✅ Otomatis compatible dengan Next.js updates
|
||||||
|
- ✅ Zero production overhead
|
||||||
|
|
||||||
|
**Kekurangan:**
|
||||||
|
- ⚠️ Hotkey berbeda (`Option+Click` vs `Ctrl+Shift+Cmd+C`)
|
||||||
|
- ⚠️ Tidak ada visual overlay/tooltip seperti di dokumen
|
||||||
|
|
||||||
|
**Cara:**
|
||||||
|
Tidak perlu melakukan apapun - fitur sudah aktif saat `bun run dev`.
|
||||||
|
|
||||||
|
### Opsi 2: Custom Implementation (JIKA DIPERLUKAN)
|
||||||
|
|
||||||
|
Jika ingin visual overlay dan tooltip seperti di dokumen, bisa dibuat custom component dengan pendekatan berbeda:
|
||||||
|
|
||||||
|
#### Arsitektur Alternatif untuk Next.js
|
||||||
|
|
||||||
|
```
|
||||||
|
BUILD TIME (Next.js/Webpack):
|
||||||
|
.tsx/.jsx file
|
||||||
|
→ [Custom Webpack Loader] inject data-inspector-* attributes
|
||||||
|
→ [Next.js internal transform] JSX to React.createElement
|
||||||
|
→ Browser menerima elemen dengan attributes
|
||||||
|
|
||||||
|
RUNTIME (Browser):
|
||||||
|
[SAMA seperti dokumen - DevInspector component]
|
||||||
|
|
||||||
|
BACKEND (Next.js API Route):
|
||||||
|
/__open-in-editor → Bun.spawn([editor, '--goto', 'file:line:col'])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Komponen yang Dibutuhkan:
|
||||||
|
|
||||||
|
1. **Custom Webpack Loader** (bukan Vite Plugin)
|
||||||
|
- Inject attributes via webpack transform
|
||||||
|
- Taruh di `next.config.ts` webpack config
|
||||||
|
|
||||||
|
2. **DevInspector Component** (sama seperti dokumen)
|
||||||
|
- Browser runtime untuk handle hotkey & klik
|
||||||
|
|
||||||
|
3. **API Route `/__open-in-editor`**
|
||||||
|
- Buat sebagai Next.js API route: `src/app/api/__open-in-editor/route.ts`
|
||||||
|
- HARUS bypass auth middleware
|
||||||
|
|
||||||
|
4. **Conditional Import** (sama seperti dokumen)
|
||||||
|
```tsx
|
||||||
|
const InspectorWrapper = process.env.NODE_ENV === 'development'
|
||||||
|
? (await import('./DevInspector')).DevInspector
|
||||||
|
: ({ children }) => <>{children}</>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Implementasi Steps:
|
||||||
|
|
||||||
|
Jika Anda ingin melanjutkan dengan custom implementation, berikut steps:
|
||||||
|
|
||||||
|
1. ✅ Buat `src/components/DevInspector.tsx` (copy dari dokumen)
|
||||||
|
2. ⚠️ Buat webpack loader untuk inject attributes (perlu research)
|
||||||
|
3. ✅ Buat API route `src/app/api/__open-in-editor/route.ts`
|
||||||
|
4. ✅ Wrap root layout dengan DevInspector
|
||||||
|
5. ✅ Set `REACT_EDITOR` di `.env`
|
||||||
|
|
||||||
|
**Peringatan:**
|
||||||
|
- Webpack loader lebih kompleks daripada Vite plugin
|
||||||
|
- Mungkin ada edge cases dengan Next.js internals
|
||||||
|
- Perlu maintenance ekstra saat Next.js update
|
||||||
|
|
||||||
|
## 📊 Perbandingan
|
||||||
|
|
||||||
|
| Fitur | Built-in Next.js | Custom Implementation |
|
||||||
|
|-------|------------------|----------------------|
|
||||||
|
| Setup | ✅ Zero | ⚠️ Medium |
|
||||||
|
| Visual Overlay | ❌ Tidak ada | ✅ Ada |
|
||||||
|
| Tooltip | ❌ Tidak ada | ✅ Ada |
|
||||||
|
| Hotkey | `Option+Click` | Custom (bisa disesuaikan) |
|
||||||
|
| Maintenance | ✅ Vercel | ⚠️ Manual |
|
||||||
|
| Compatibility | ✅ Guaranteed | ⚠️ Perlu testing |
|
||||||
|
| Production Impact | ✅ Zero | ✅ Zero (dengan conditional import) |
|
||||||
|
|
||||||
|
## 🎯 Kesimpulan
|
||||||
|
|
||||||
|
**Rekomendasi: Gunakan Built-in Next.js**
|
||||||
|
|
||||||
|
Alasan:
|
||||||
|
1. ✅ Sudah tersedia - tidak perlu setup
|
||||||
|
2. ✅ Lebih stabil - maintain oleh Vercel
|
||||||
|
3. ✅ Lebih simple - tidak ada custom code
|
||||||
|
4. ✅ Future-proof - otomatis update dengan Next.js
|
||||||
|
|
||||||
|
**Custom implementation hanya diperlukan jika:**
|
||||||
|
- Anda sangat membutuhkan visual overlay & tooltip
|
||||||
|
- Anda ingin hotkey yang sama persis (`Ctrl+Shift+Cmd+C`)
|
||||||
|
- Anda punya waktu untuk maintenance
|
||||||
|
|
||||||
|
## 🚀 Quick Start - Built-in Feature
|
||||||
|
|
||||||
|
Untuk menggunakan click-to-source bawaan Next.js:
|
||||||
|
|
||||||
|
1. Jalankan development server:
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Buka browser ke `http://localhost:3000`
|
||||||
|
|
||||||
|
3. Tahan `Option` (macOS) atau `Alt` (Windows/Linux)
|
||||||
|
|
||||||
|
4. Cursor akan berubah menjadi crosshair
|
||||||
|
|
||||||
|
5. Klik elemen mana pun - file akan terbuka di editor
|
||||||
|
|
||||||
|
6. **Opsional**: Set editor di `.env`:
|
||||||
|
```env
|
||||||
|
# .env.local
|
||||||
|
EDITOR=code # atau cursor, windsurf, subl
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- Fitur ini hanya aktif di development mode (`NODE_ENV=development`)
|
||||||
|
- Production build (`bun run build`) otomatis menghilangkan fitur ini
|
||||||
|
- Next.js menggunakan mekanisme yang mirip (source mapping) untuk menentukan lokasi component
|
||||||
|
- Jika editor tidak terbuka, pastikan:
|
||||||
|
- Editor sudah terinstall dan ada di PATH
|
||||||
|
- Browser DevTools terbuka (beberapa browser require ini)
|
||||||
|
- Anda menggunakan development server, bukan production
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
- [Next.js Documentation - Launching Editor](https://nextjs.org/docs/app/api-reference/config/next-config-js/reactStrictMode)
|
||||||
|
- [React DevTools - Component Inspection](https://react.dev/learn/react-developer-tools)
|
||||||
|
- [Original Dev Inspector Document](./dev-inspector-click-to-source.md)
|
||||||
89
Dockerfile
89
Dockerfile
@@ -1,60 +1,67 @@
|
|||||||
# Stage 1: Build
|
# ==============================
|
||||||
FROM oven/bun:1.3 AS build
|
# Stage 1: Builder
|
||||||
|
# ==============================
|
||||||
|
FROM oven/bun:1-debian AS builder
|
||||||
|
|
||||||
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY package.json bun.lock* ./
|
libc6 \
|
||||||
|
git \
|
||||||
|
openssl \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY package.json bun.lockb* ./
|
||||||
|
|
||||||
|
ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
# Copy the rest of the application code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Use .env.example as default env for build
|
RUN cp .env.example .env || true
|
||||||
RUN cp .env.example .env
|
|
||||||
|
|
||||||
# Generate Prisma client
|
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
|
||||||
RUN bun x prisma generate
|
RUN bunx prisma generate
|
||||||
|
|
||||||
|
# Generate API types (opsional)
|
||||||
|
RUN bun run gen:api || echo "tidak ada gen api"
|
||||||
|
|
||||||
# Build the application frontend
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# ==============================
|
||||||
FROM oven/bun:1.3-slim AS runtime
|
# Stage 2: Runner (Production)
|
||||||
|
# ==============================
|
||||||
|
FROM oven/bun:1-debian AS runner
|
||||||
|
|
||||||
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy necessary files from build stage
|
ENV NODE_ENV=production
|
||||||
COPY --from=build /app/package.json ./
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY --from=build /app/tsconfig.json ./
|
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
|
||||||
COPY --from=build /app/.next ./.next
|
ENV PORT=3000
|
||||||
COPY --from=build /app/public ./public
|
ENV HOSTNAME="0.0.0.0"
|
||||||
COPY --from=build /app/src ./src
|
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY --from=build /app/prisma ./prisma
|
openssl \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 1001 nodejs \
|
||||||
|
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/next.config.* ./
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
# Expose the port
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start the application
|
|
||||||
CMD ["bun", "start"]
|
CMD ["bun", "start"]
|
||||||
553
dev-inspector-click-to-source.md
Normal file
553
dev-inspector-click-to-source.md
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
# Skill: Dev Inspector — Click-to-Source untuk Bun + Elysia + Vite + React
|
||||||
|
|
||||||
|
## Ringkasan
|
||||||
|
|
||||||
|
Fitur development: klik elemen UI di browser → langsung buka source code di editor (VS Code, Cursor, dll) pada baris dan kolom yang tepat. Zero overhead di production.
|
||||||
|
|
||||||
|
**Hotkey**: `Ctrl+Shift+Cmd+C` (macOS) / `Ctrl+Shift+Alt+C` → aktifkan mode inspect → klik elemen → file terbuka.
|
||||||
|
|
||||||
|
## Kenapa Tidak Pakai Library
|
||||||
|
|
||||||
|
`react-dev-inspector` crash di React 19 karena:
|
||||||
|
- `fiber.return.child.sibling` bisa null di React 19
|
||||||
|
- `_debugSource` dihapus dari React 19
|
||||||
|
- Walking fiber tree tidak stabil antar versi React
|
||||||
|
|
||||||
|
Solusi ini **regex-based + multi-fallback**, tidak bergantung pada React internals.
|
||||||
|
|
||||||
|
## Syarat Arsitektur
|
||||||
|
|
||||||
|
Fitur ini bekerja karena 4 syarat struktural terpenuhi. Jika salah satu tidak ada, fitur tidak bisa diimplementasi atau perlu adaptasi signifikan.
|
||||||
|
|
||||||
|
### 1. Vite sebagai Bundler (Wajib)
|
||||||
|
|
||||||
|
Seluruh mekanisme bergantung pada **Vite plugin transform pipeline**:
|
||||||
|
- `inspectorPlugin()` inject attributes ke JSX saat build/HMR
|
||||||
|
- `enforce: 'pre'` memastikan plugin jalan sebelum OXC/Babel transform JSX
|
||||||
|
- `import.meta.env?.DEV` sebagai compile-time constant untuk tree-shaking
|
||||||
|
|
||||||
|
**Tidak bisa diganti dengan**: esbuild standalone, webpack (perlu loader berbeda), SWC standalone.
|
||||||
|
**Bisa diganti dengan**: framework yang pakai Vite di dalamnya (Remix Vite, TanStack Start, Astro).
|
||||||
|
|
||||||
|
### 2. Server dan Frontend dalam Satu Proses (Wajib)
|
||||||
|
|
||||||
|
Endpoint `/__open-in-editor` harus **satu proses dengan dev server** yang melayani frontend:
|
||||||
|
- Browser POST ke origin yang sama (no CORS)
|
||||||
|
- Server punya akses ke filesystem lokal untuk `Bun.spawn(editor)`
|
||||||
|
- Endpoint harus bisa ditangani **sebelum routing & middleware** (auth, tenant, dll)
|
||||||
|
|
||||||
|
**Pola yang memenuhi syarat:**
|
||||||
|
- Elysia + Vite middlewareMode (project ini) — `onRequest` intercept sebelum route matching
|
||||||
|
- Express/Fastify + Vite middlewareMode — middleware biasa sebelum auth
|
||||||
|
- Vite dev server standalone (`vite dev`) — pakai `configureServer` hook
|
||||||
|
|
||||||
|
**Tidak memenuhi syarat:**
|
||||||
|
- Frontend dan backend di proses/port terpisah (misal: CRA + separate API server) — perlu proxy atau CORS config tambahan
|
||||||
|
- Serverless/edge deployment — tidak bisa `spawn` editor
|
||||||
|
|
||||||
|
### 3. React sebagai UI Framework (Wajib untuk Multi-Fallback)
|
||||||
|
|
||||||
|
Strategi extraction source info bergantung pada React internals:
|
||||||
|
1. `__reactProps$*` — React menyimpan props di DOM element
|
||||||
|
2. `__reactFiber$*` — React fiber tree untuk walk-up
|
||||||
|
3. DOM attribute — fallback universal
|
||||||
|
|
||||||
|
**Jika pakai framework lain** (Vue, Svelte, Solid):
|
||||||
|
- Hanya strategi 3 (DOM attribute) yang berfungsi — tetap cukup
|
||||||
|
- Hapus strategi 1 & 2 dari `getCodeInfoFromElement()`
|
||||||
|
- Inject attributes tetap via Vite plugin (framework-agnostic)
|
||||||
|
|
||||||
|
### 4. Bun sebagai Runtime (Direkomendasikan, Bukan Wajib)
|
||||||
|
|
||||||
|
Bun memberikan API yang lebih clean:
|
||||||
|
- `Bun.spawn()` — fire-and-forget tanpa import
|
||||||
|
- `Bun.which()` — cek executable ada di PATH (mencegah uncatchable error)
|
||||||
|
|
||||||
|
**Jika pakai Node.js:**
|
||||||
|
- `Bun.spawn()` → `child_process.spawn(editor, args, { detached: true, stdio: 'ignore' }).unref()`
|
||||||
|
- `Bun.which()` → `const which = require('which'); which.sync(editor, { nothrow: true })`
|
||||||
|
|
||||||
|
### Ringkasan Syarat
|
||||||
|
|
||||||
|
| Syarat | Wajib? | Alternatif |
|
||||||
|
|-------------------------------|----------|------------------------------------------------------|
|
||||||
|
| Vite sebagai bundler | Ya | Framework berbasis Vite (Remix, Astro, dll) |
|
||||||
|
| Server + frontend satu proses | Ya | Bisa diakali dengan proxy, tapi tambah kompleksitas |
|
||||||
|
| React | Sebagian | Framework lain bisa, hanya fallback ke DOM attribute |
|
||||||
|
| Bun runtime | Tidak | Node.js dengan `child_process` + `which` package |
|
||||||
|
|
||||||
|
## Arsitektur
|
||||||
|
|
||||||
|
```
|
||||||
|
BUILD TIME (Vite Plugin):
|
||||||
|
.tsx/.jsx file
|
||||||
|
→ [inspectorPlugin enforce:'pre'] inject data-inspector-* attributes ke JSX
|
||||||
|
→ [react() OXC] transform JSX ke createElement
|
||||||
|
→ Browser menerima elemen dengan attributes
|
||||||
|
|
||||||
|
RUNTIME (Browser):
|
||||||
|
Hotkey → aktifkan mode → hover elemen → baca attributes → klik
|
||||||
|
→ POST /__open-in-editor {relativePath, line, column}
|
||||||
|
|
||||||
|
BACKEND (Elysia onRequest):
|
||||||
|
/__open-in-editor → Bun.spawn([editor, '--goto', 'file:line:col'])
|
||||||
|
→ Editor terbuka di lokasi tepat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Komponen yang Dibutuhkan
|
||||||
|
|
||||||
|
### 1. Vite Plugin — `inspectorPlugin()` (enforce: 'pre')
|
||||||
|
|
||||||
|
Inject `data-inspector-*` ke setiap JSX opening tag via regex.
|
||||||
|
|
||||||
|
**HARUS `enforce: 'pre'`** — kalau tidak, OXC transform JSX duluan dan regex tidak bisa menemukan `<Component`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Taruh di file vite config (misal: src/vite.ts atau vite.config.ts)
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
function inspectorPlugin(): Plugin {
|
||||||
|
const rootDir = process.cwd()
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'inspector-inject',
|
||||||
|
enforce: 'pre',
|
||||||
|
transform(code, id) {
|
||||||
|
// Hanya .tsx/.jsx, skip node_modules
|
||||||
|
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
|
||||||
|
if (!code.includes('<')) return null
|
||||||
|
|
||||||
|
const relativePath = path.relative(rootDir, id)
|
||||||
|
let modified = false
|
||||||
|
const lines = code.split('\n')
|
||||||
|
const result: string[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let line = lines[i]
|
||||||
|
// Match JSX opening tags: <Component atau <div
|
||||||
|
// Skip TypeScript generics (Record<string>) via charBefore check
|
||||||
|
const jsxPattern = /(<(?:[A-Z][a-zA-Z0-9.]*|[a-z][a-zA-Z0-9-]*))\b/g
|
||||||
|
let match: RegExpExecArray | null = null
|
||||||
|
|
||||||
|
while ((match = jsxPattern.exec(line)) !== null) {
|
||||||
|
// Skip jika karakter sebelum `<` adalah identifier char (TypeScript generic)
|
||||||
|
const charBefore = match.index > 0 ? line[match.index - 1] : ''
|
||||||
|
if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue
|
||||||
|
|
||||||
|
const col = match.index + 1
|
||||||
|
const attr = ` data-inspector-line="${i + 1}" data-inspector-column="${col}" data-inspector-relative-path="${relativePath}"`
|
||||||
|
const insertPos = match.index + match[0].length
|
||||||
|
line = line.slice(0, insertPos) + attr + line.slice(insertPos)
|
||||||
|
modified = true
|
||||||
|
jsxPattern.lastIndex += attr.length
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modified) return null
|
||||||
|
return result.join('\n')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mengapa regex, bukan Babel?**
|
||||||
|
- `@vitejs/plugin-react` v6+ pakai OXC (Rust), bukan Babel
|
||||||
|
- Config `babel: { plugins: [...] }` di plugin-react **DIABAIKAN**
|
||||||
|
- Regex jalan sebelum OXC via `enforce: 'pre'`
|
||||||
|
|
||||||
|
**Gotcha: TypeScript generics**
|
||||||
|
- `Record<string>` → karakter sebelum `<` adalah `d` (identifier) → SKIP
|
||||||
|
- `<Button` → karakter sebelum `<` adalah space/newline → MATCH
|
||||||
|
|
||||||
|
### 2. Vite Plugin Order (KRITIS)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
plugins: [
|
||||||
|
// 1. Route generation (jika pakai TanStack Router)
|
||||||
|
TanStackRouterVite({ ... }),
|
||||||
|
|
||||||
|
// 2. Inspector inject — HARUS sebelum react()
|
||||||
|
inspectorPlugin(),
|
||||||
|
|
||||||
|
// 3. React OXC transform
|
||||||
|
react(),
|
||||||
|
|
||||||
|
// 4. (Opsional) Dedupe React Refresh untuk middlewareMode
|
||||||
|
dedupeRefreshPlugin(),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Jika urutan salah (inspectorPlugin setelah react):**
|
||||||
|
- OXC transform `<Button>` → `React.createElement(Button, ...)`
|
||||||
|
- Regex tidak menemukan `<Button` → attributes TIDAK ter-inject
|
||||||
|
- Fitur tidak berfungsi, tanpa error
|
||||||
|
|
||||||
|
### 3. DevInspector Component (Browser Runtime)
|
||||||
|
|
||||||
|
Komponen React yang handle hotkey, overlay, dan klik.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/frontend/DevInspector.tsx
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface CodeInfo {
|
||||||
|
relativePath: string
|
||||||
|
line: string
|
||||||
|
column: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baca data-inspector-* dari fiber props atau DOM attributes */
|
||||||
|
function getCodeInfoFromElement(element: HTMLElement): CodeInfo | null {
|
||||||
|
// Strategi 1: React internal props __reactProps$ (paling akurat)
|
||||||
|
for (const key of Object.keys(element)) {
|
||||||
|
if (key.startsWith('__reactProps$')) {
|
||||||
|
const props = (element as any)[key]
|
||||||
|
if (props?.['data-inspector-relative-path']) {
|
||||||
|
return {
|
||||||
|
relativePath: props['data-inspector-relative-path'],
|
||||||
|
line: props['data-inspector-line'] || '1',
|
||||||
|
column: props['data-inspector-column'] || '1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strategi 2: Walk fiber tree __reactFiber$
|
||||||
|
if (key.startsWith('__reactFiber$')) {
|
||||||
|
const fiber = (element as any)[key]
|
||||||
|
let f = fiber
|
||||||
|
while (f) {
|
||||||
|
const p = f.pendingProps || f.memoizedProps
|
||||||
|
if (p?.['data-inspector-relative-path']) {
|
||||||
|
return {
|
||||||
|
relativePath: p['data-inspector-relative-path'],
|
||||||
|
line: p['data-inspector-line'] || '1',
|
||||||
|
column: p['data-inspector-column'] || '1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: _debugSource (React < 19)
|
||||||
|
const src = f._debugSource ?? f._debugOwner?._debugSource
|
||||||
|
if (src?.fileName && src?.lineNumber) {
|
||||||
|
return {
|
||||||
|
relativePath: src.fileName,
|
||||||
|
line: String(src.lineNumber),
|
||||||
|
column: String(src.columnNumber ?? 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f = f.return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategi 3: Fallback DOM attribute langsung
|
||||||
|
const rp = element.getAttribute('data-inspector-relative-path')
|
||||||
|
if (rp) {
|
||||||
|
return {
|
||||||
|
relativePath: rp,
|
||||||
|
line: element.getAttribute('data-inspector-line') || '1',
|
||||||
|
column: element.getAttribute('data-inspector-column') || '1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk up DOM tree sampai ketemu elemen yang punya source info */
|
||||||
|
function findCodeInfo(target: HTMLElement): CodeInfo | null {
|
||||||
|
let el: HTMLElement | null = target
|
||||||
|
while (el) {
|
||||||
|
const info = getCodeInfoFromElement(el)
|
||||||
|
if (info) return info
|
||||||
|
el = el.parentElement
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInEditor(info: CodeInfo) {
|
||||||
|
fetch('/__open-in-editor', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
relativePath: info.relativePath,
|
||||||
|
lineNumber: info.line,
|
||||||
|
columnNumber: info.column,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevInspector({ children }: { children: React.ReactNode }) {
|
||||||
|
const [active, setActive] = useState(false)
|
||||||
|
const overlayRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const lastInfoRef = useRef<CodeInfo | null>(null)
|
||||||
|
|
||||||
|
const updateOverlay = useCallback((target: HTMLElement | null) => {
|
||||||
|
const ov = overlayRef.current
|
||||||
|
const tt = tooltipRef.current
|
||||||
|
if (!ov || !tt) return
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
ov.style.display = 'none'
|
||||||
|
tt.style.display = 'none'
|
||||||
|
lastInfoRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = findCodeInfo(target)
|
||||||
|
if (!info) {
|
||||||
|
ov.style.display = 'none'
|
||||||
|
tt.style.display = 'none'
|
||||||
|
lastInfoRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastInfoRef.current = info
|
||||||
|
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
ov.style.display = 'block'
|
||||||
|
ov.style.top = `${rect.top + window.scrollY}px`
|
||||||
|
ov.style.left = `${rect.left + window.scrollX}px`
|
||||||
|
ov.style.width = `${rect.width}px`
|
||||||
|
ov.style.height = `${rect.height}px`
|
||||||
|
|
||||||
|
tt.style.display = 'block'
|
||||||
|
tt.textContent = `${info.relativePath}:${info.line}`
|
||||||
|
const ttTop = rect.top + window.scrollY - 24
|
||||||
|
tt.style.top = `${ttTop > 0 ? ttTop : rect.bottom + window.scrollY + 4}px`
|
||||||
|
tt.style.left = `${rect.left + window.scrollX}px`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Activate/deactivate event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
const onMouseOver = (e: MouseEvent) => updateOverlay(e.target as HTMLElement)
|
||||||
|
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const info = lastInfoRef.current ?? findCodeInfo(e.target as HTMLElement)
|
||||||
|
if (info) {
|
||||||
|
const loc = `${info.relativePath}:${info.line}:${info.column}`
|
||||||
|
console.log('[DevInspector] Open:', loc)
|
||||||
|
navigator.clipboard.writeText(loc)
|
||||||
|
openInEditor(info)
|
||||||
|
}
|
||||||
|
setActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mouseover', onMouseOver, true)
|
||||||
|
document.addEventListener('click', onClick, true)
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.body.style.cursor = 'crosshair'
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseover', onMouseOver, true)
|
||||||
|
document.removeEventListener('click', onClick, true)
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
if (overlayRef.current) overlayRef.current.style.display = 'none'
|
||||||
|
if (tooltipRef.current) tooltipRef.current.style.display = 'none'
|
||||||
|
}
|
||||||
|
}, [active, updateOverlay])
|
||||||
|
|
||||||
|
// Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key.toLowerCase() === 'c' && e.ctrlKey && e.shiftKey && (e.metaKey || e.altKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
setActive((prev) => !prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
border: '2px solid #3b82f6',
|
||||||
|
backgroundColor: 'rgba(59,130,246,0.1)',
|
||||||
|
zIndex: 99999,
|
||||||
|
transition: 'all 0.05s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
zIndex: 100000,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Backend Endpoint — `/__open-in-editor`
|
||||||
|
|
||||||
|
**HARUS ditangani di `onRequest` / sebelum middleware**, bukan sebagai route biasa. Kalau jadi route, akan kena auth middleware dan gagal.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Di entry point server (src/index.tsx), dalam onRequest handler:
|
||||||
|
|
||||||
|
if (!isProduction && pathname === '/__open-in-editor' && request.method === 'POST') {
|
||||||
|
const { relativePath, lineNumber, columnNumber } = (await request.json()) as {
|
||||||
|
relativePath: string
|
||||||
|
lineNumber: string
|
||||||
|
columnNumber: string
|
||||||
|
}
|
||||||
|
const file = `${process.cwd()}/${relativePath}`
|
||||||
|
const editor = process.env.REACT_EDITOR || 'code'
|
||||||
|
const loc = `${file}:${lineNumber}:${columnNumber}`
|
||||||
|
const args = editor === 'subl' ? [loc] : ['--goto', loc]
|
||||||
|
const editorPath = Bun.which(editor)
|
||||||
|
console.log(`[inspector] ${editor} → ${editorPath ?? 'NOT FOUND'} → ${loc}`)
|
||||||
|
if (editorPath) {
|
||||||
|
Bun.spawn([editor, ...args], { stdio: ['ignore', 'ignore', 'ignore'] })
|
||||||
|
} else {
|
||||||
|
console.error(`[inspector] Editor "${editor}" not found in PATH. Set REACT_EDITOR in .env`)
|
||||||
|
}
|
||||||
|
return new Response('ok')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Penting — `Bun.which()` sebelum `Bun.spawn()`:**
|
||||||
|
- `Bun.spawn()` throw native error yang TIDAK bisa di-catch jika executable tidak ada
|
||||||
|
- `Bun.which()` return null dengan aman → cek dulu sebelum spawn
|
||||||
|
|
||||||
|
**Editor yang didukung:**
|
||||||
|
|
||||||
|
| REACT_EDITOR | Editor | Args |
|
||||||
|
|------------------|--------------|--------------------------------|
|
||||||
|
| `code` (default) | VS Code | `--goto file:line:col` |
|
||||||
|
| `cursor` | Cursor | `--goto file:line:col` |
|
||||||
|
| `windsurf` | Windsurf | `--goto file:line:col` |
|
||||||
|
| `subl` | Sublime Text | `file:line:col` (tanpa --goto) |
|
||||||
|
|
||||||
|
### 5. Frontend Entry — Conditional Import (Zero Production Overhead)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/frontend.tsx (atau entry point React)
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const InspectorWrapper = import.meta.env?.DEV
|
||||||
|
? (await import('./frontend/DevInspector')).DevInspector
|
||||||
|
: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||||
|
|
||||||
|
const app = (
|
||||||
|
<InspectorWrapper>
|
||||||
|
<App />
|
||||||
|
</InspectorWrapper>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bagaimana zero overhead tercapai:**
|
||||||
|
- `import.meta.env?.DEV` adalah compile-time constant
|
||||||
|
- Production build: `false` → dynamic import TIDAK dieksekusi
|
||||||
|
- Tree-shaking menghapus seluruh `DevInspector.tsx` dari bundle
|
||||||
|
- Tidak ada runtime check, tidak ada dead code di bundle
|
||||||
|
|
||||||
|
### 6. (Opsional) Dedupe React Refresh — Workaround Vite middlewareMode
|
||||||
|
|
||||||
|
Jika pakai Vite dalam `middlewareMode` (seperti di Elysia/Express), `@vitejs/plugin-react` v6 bisa inject React Refresh footer dua kali → error "already declared".
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function dedupeRefreshPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'dedupe-react-refresh',
|
||||||
|
enforce: 'post',
|
||||||
|
transform(code, id) {
|
||||||
|
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
|
||||||
|
|
||||||
|
const marker = 'import * as RefreshRuntime from "/@react-refresh"'
|
||||||
|
const firstIdx = code.indexOf(marker)
|
||||||
|
if (firstIdx === -1) return null
|
||||||
|
|
||||||
|
const secondIdx = code.indexOf(marker, firstIdx + marker.length)
|
||||||
|
if (secondIdx === -1) return null
|
||||||
|
|
||||||
|
const sourcemapIdx = code.indexOf('\n//# sourceMappingURL=', secondIdx)
|
||||||
|
const endIdx = sourcemapIdx !== -1 ? sourcemapIdx : code.length
|
||||||
|
|
||||||
|
const cleaned = code.slice(0, secondIdx) + code.slice(endIdx)
|
||||||
|
return { code: cleaned, map: null }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Langkah Implementasi di Project Baru
|
||||||
|
|
||||||
|
### Prasyarat
|
||||||
|
- Runtime: Bun
|
||||||
|
- Server: Elysia (atau framework lain dengan onRequest/beforeHandle)
|
||||||
|
- Frontend: React + Vite
|
||||||
|
- `@vitejs/plugin-react` (OXC)
|
||||||
|
|
||||||
|
### Step-by-step
|
||||||
|
|
||||||
|
1. **Buat `DevInspector.tsx`** — copy komponen dari Bagian 3 ke folder frontend
|
||||||
|
2. **Tambah `inspectorPlugin()`** — copy fungsi dari Bagian 1 ke file vite config
|
||||||
|
3. **Atur plugin order** — `inspectorPlugin()` SEBELUM `react()` (Bagian 2)
|
||||||
|
4. **Tambah endpoint `/__open-in-editor`** — di `onRequest` handler (Bagian 4)
|
||||||
|
5. **Wrap root app** — conditional import di entry point (Bagian 5)
|
||||||
|
6. **Set env** — `REACT_EDITOR=code` (atau cursor/windsurf/subl) di `.env`
|
||||||
|
7. **(Opsional)** Tambah `dedupeRefreshPlugin()` jika pakai Vite `middlewareMode`
|
||||||
|
|
||||||
|
### Checklist Verifikasi
|
||||||
|
|
||||||
|
- [ ] `inspectorPlugin` punya `enforce: 'pre'`
|
||||||
|
- [ ] Plugin order: inspector → react (bukan sebaliknya)
|
||||||
|
- [ ] Endpoint `/__open-in-editor` di LUAR middleware auth
|
||||||
|
- [ ] `Bun.which(editor)` dipanggil SEBELUM `Bun.spawn()`
|
||||||
|
- [ ] Conditional import pakai `import.meta.env?.DEV`
|
||||||
|
- [ ] `REACT_EDITOR` di `.env` sesuai editor yang dipakai
|
||||||
|
- [ ] Hotkey berfungsi: `Ctrl+Shift+Cmd+C` / `Ctrl+Shift+Alt+C`
|
||||||
|
|
||||||
|
## Gotcha & Pelajaran
|
||||||
|
|
||||||
|
| Masalah | Penyebab | Solusi |
|
||||||
|
|----------------------------------|---------------------------------------------|-----------------------------------------------|
|
||||||
|
| Attributes tidak ter-inject | Plugin order salah | `enforce: 'pre'`, taruh sebelum `react()` |
|
||||||
|
| `Record<string>` ikut ter-inject | Regex match TypeScript generics | Cek `charBefore` — skip jika identifier char |
|
||||||
|
| `Bun.spawn` crash | Editor tidak ada di PATH | Selalu `Bun.which()` dulu |
|
||||||
|
| Hotkey tidak response | `e.key` return 'C' (uppercase) karena Shift | Pakai `e.key.toLowerCase()` |
|
||||||
|
| React Refresh duplicate | Vite middlewareMode bug | `dedupeRefreshPlugin()` enforce: 'post' |
|
||||||
|
| Endpoint kena auth middleware | Didaftarkan sebagai route biasa | Tangani di `onRequest` sebelum routing |
|
||||||
|
| `_debugSource` undefined | React 19 menghapusnya | Multi-fallback: reactProps → fiber → DOM attr |
|
||||||
|
|
||||||
|
## Adaptasi untuk Framework Lain
|
||||||
|
|
||||||
|
### Express/Fastify (bukan Elysia)
|
||||||
|
- Endpoint `/__open-in-editor`: gunakan middleware biasa SEBELUM auth
|
||||||
|
- `Bun.spawn` → `child_process.spawn` jika pakai Node.js
|
||||||
|
- `Bun.which` → `which` npm package jika pakai Node.js
|
||||||
|
|
||||||
|
### Next.js
|
||||||
|
- Tidak perlu — Next.js punya built-in click-to-source
|
||||||
|
- Tapi jika ingin custom: taruh endpoint di `middleware.ts`, plugin di `next.config.js`
|
||||||
|
|
||||||
|
### Remix/Tanstack Start (SSR)
|
||||||
|
- Plugin tetap sama (Vite-based)
|
||||||
|
- Endpoint perlu di server entry, bukan di route loader
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test:api": "vitest run",
|
"test:api": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test": "bun run test:api && bun run test:e2e"
|
"test": "bun run test:api && bun run test:e2e",
|
||||||
|
"gen:api": ""
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "bun run prisma/seed.ts"
|
"seed": "bun run prisma/seed.ts"
|
||||||
@@ -120,10 +121,11 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@vitest/ui": "^4.0.18",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.6",
|
"eslint-config-next": "15.5.12",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"msw": "^2.12.9",
|
"msw": "^2.12.9",
|
||||||
"parcel": "^2.6.2",
|
"parcel": "^2.6.2",
|
||||||
|
"playwright-mcp": "^0.0.19",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
|||||||
94
prisma/migrations/20260406032433_init/migration.sql
Normal file
94
prisma/migrations/20260406032433_init/migration.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `realisasi` on the `APBDesItem` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "APBDesItem" DROP COLUMN "realisasi",
|
||||||
|
ADD COLUMN "totalRealisasi" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
ALTER COLUMN "selisih" SET DEFAULT 0,
|
||||||
|
ALTER COLUMN "persentase" SET DEFAULT 0;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Berita" ADD COLUMN "linkVideo" VARCHAR(500);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RealisasiItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kode" TEXT,
|
||||||
|
"apbdesItemId" TEXT NOT NULL,
|
||||||
|
"jumlah" DOUBLE PRECISION NOT NULL,
|
||||||
|
"tanggal" DATE NOT NULL,
|
||||||
|
"keterangan" TEXT,
|
||||||
|
"buktiFileId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "RealisasiItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MusikDesa" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"judul" VARCHAR(255) NOT NULL,
|
||||||
|
"artis" VARCHAR(255) NOT NULL,
|
||||||
|
"deskripsi" TEXT,
|
||||||
|
"durasi" VARCHAR(20) NOT NULL,
|
||||||
|
"audioFileId" TEXT,
|
||||||
|
"coverImageId" TEXT,
|
||||||
|
"genre" VARCHAR(100),
|
||||||
|
"tahunRilis" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "MusikDesa_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_BeritaImages" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_BeritaImages_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RealisasiItem_kode_idx" ON "RealisasiItem"("kode");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RealisasiItem_apbdesItemId_idx" ON "RealisasiItem"("apbdesItemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RealisasiItem_tanggal_idx" ON "RealisasiItem"("tanggal");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MusikDesa_judul_idx" ON "MusikDesa"("judul");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MusikDesa_artis_idx" ON "MusikDesa"("artis");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_BeritaImages_B_index" ON "_BeritaImages"("B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Berita_kategoriBeritaId_idx" ON "Berita"("kategoriBeritaId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RealisasiItem" ADD CONSTRAINT "RealisasiItem_apbdesItemId_fkey" FOREIGN KEY ("apbdesItemId") REFERENCES "APBDesItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MusikDesa" ADD CONSTRAINT "MusikDesa_audioFileId_fkey" FOREIGN KEY ("audioFileId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MusikDesa" ADD CONSTRAINT "MusikDesa_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_BeritaImages" ADD CONSTRAINT "_BeritaImages_A_fkey" FOREIGN KEY ("A") REFERENCES "Berita"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_BeritaImages" ADD CONSTRAINT "_BeritaImages_B_fkey" FOREIGN KEY ("B") REFERENCES "FileStorage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
32
src/app/api/auth/_lib/sendCodeOtp.ts
Normal file
32
src/app/api/auth/_lib/sendCodeOtp.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// app/api/auth/_lib/sendCodeOtp.ts
|
||||||
|
|
||||||
|
const sendCodeOtp = async ({
|
||||||
|
nomor,
|
||||||
|
codeOtp,
|
||||||
|
newMessage,
|
||||||
|
}: {
|
||||||
|
nomor: string;
|
||||||
|
codeOtp?: string | number;
|
||||||
|
newMessage?: string;
|
||||||
|
}) => {
|
||||||
|
const msg =
|
||||||
|
newMessage ||
|
||||||
|
`Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
||||||
|
const enCode = msg;
|
||||||
|
|
||||||
|
const res = await fetch(`https://otp.wibudev.com/api/wa/send-text`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${process.env.WA_SERVER_TOKEN}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
number: nomor,
|
||||||
|
text: enCode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { sendCodeOtp };
|
||||||
@@ -2,16 +2,10 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { randomOTP } from "../_lib/randomOTP";
|
import { randomOTP } from "../_lib/randomOTP";
|
||||||
|
import { sendCodeOtp } from "../_lib/sendCodeOtp";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
if (req.method !== "POST") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, message: "Method Not Allowed" },
|
|
||||||
{ status: 405 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { nomor } = await req.json();
|
const { nomor } = await req.json();
|
||||||
|
|
||||||
@@ -35,26 +29,40 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
console.log(`🔑 DEBUG OTP [${nomor}]: ${codeOtp}`);
|
console.log(`🔑 DEBUG OTP [${nomor}]: ${codeOtp}`);
|
||||||
|
|
||||||
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
let waSuccess = false;
|
||||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
let waErrorMessage = "";
|
||||||
|
|
||||||
console.log("🔍 Debug WA URL:", waUrl);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(waUrl);
|
const waResponse = await sendCodeOtp({
|
||||||
if (!res.ok) {
|
nomor,
|
||||||
console.error(`⚠️ WA Service HTTP Error: ${res.status} ${res.statusText}. Continuing since OTP is logged.`);
|
codeOtp,
|
||||||
console.log(`💡 Use this OTP to login: ${codeOtp}`);
|
});
|
||||||
|
|
||||||
|
if (!waResponse.ok) {
|
||||||
|
waErrorMessage = `WA Service HTTP Error: ${waResponse.status} ${waResponse.statusText}`;
|
||||||
|
console.error(`⚠️ ${waErrorMessage}. Continuing since OTP is logged.`);
|
||||||
|
console.log(`💡 Use this OTP to login: ${codeOtp}`);
|
||||||
} else {
|
} else {
|
||||||
const sendWa = await res.json();
|
const sendWa = await waResponse.json();
|
||||||
console.log("📱 WA Response:", sendWa);
|
console.log("📱 WA Response:", sendWa);
|
||||||
|
|
||||||
if (sendWa.status !== "success") {
|
if (sendWa.status !== "success") {
|
||||||
console.error("⚠️ WA Service Logic Error:", sendWa);
|
waErrorMessage = `WA Service Logic Error: ${JSON.stringify(sendWa)}`;
|
||||||
|
console.error("⚠️", waErrorMessage);
|
||||||
|
} else {
|
||||||
|
waSuccess = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (waError: unknown) {
|
} catch (waError: unknown) {
|
||||||
const errorMessage = waError instanceof Error ? waError.message : String(waError);
|
const errorMessage =
|
||||||
console.error("⚠️ WA Connection Exception. Continuing since OTP is logged.", errorMessage);
|
waError instanceof Error ? waError.message : String(waError);
|
||||||
|
|
||||||
|
waErrorMessage = `WA Connection Exception: ${errorMessage}`;
|
||||||
|
console.error(
|
||||||
|
"⚠️",
|
||||||
|
waErrorMessage,
|
||||||
|
". Continuing since OTP is logged."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createOtpId = await prisma.kodeOtp.create({
|
const createOtpId = await prisma.kodeOtp.create({
|
||||||
@@ -62,19 +70,33 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set('auth_flow', 'login', {
|
cookieStore.set("auth_flow", "login", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: 'lax',
|
sameSite: "lax",
|
||||||
maxAge: 60 * 5, // 5 menit
|
maxAge: 60 * 5,
|
||||||
path: '/'
|
path: "/",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Include debug info for non-production environments when WA fails
|
||||||
|
const isNonProd = process.env.NODE_ENV !== "production";
|
||||||
|
const includeDebug = isNonProd && !waSuccess;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Kode verifikasi dikirim",
|
message: waSuccess
|
||||||
|
? "Kode verifikasi dikirim"
|
||||||
|
: "Kode verifikasi dibuat (WhatsApp gagal terkirim)",
|
||||||
kodeId: createOtpId.id,
|
kodeId: createOtpId.id,
|
||||||
isRegistered: true,
|
isRegistered: true,
|
||||||
|
waSuccess,
|
||||||
|
...(includeDebug && {
|
||||||
|
debug: {
|
||||||
|
codeOtp,
|
||||||
|
waErrorMessage,
|
||||||
|
note: "Hanya muncul di development/staging. Hapus di production.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -85,6 +107,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Error Login:", error);
|
console.error("❌ Error Login:", error);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: "Terjadi kesalahan saat login" },
|
{ success: false, message: "Terjadi kesalahan saat login" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
160
src/app/api/health/otp/route.ts
Normal file
160
src/app/api/health/otp/route.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// app/api/health/otp/route.ts
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomOTP } from "@/app/api/auth/_lib/randomOTP";
|
||||||
|
import { sendCodeOtp } from "@/app/api/auth/_lib/sendCodeOtp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check endpoint untuk OTP WhatsApp service
|
||||||
|
*
|
||||||
|
* GET /api/health/otp?test=true&number=6281234567890
|
||||||
|
*
|
||||||
|
* Query parameters:
|
||||||
|
* - test: Set "true" untuk kirim test OTP (optional)
|
||||||
|
* - number: Nomor tujuan test (required jika test=true)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* - Status OTP service (available/unavailable)
|
||||||
|
* - Token configuration status
|
||||||
|
* - Test message results (jika test=true)
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const isTest = searchParams.get("test") === "true";
|
||||||
|
const testNumber = searchParams.get("number");
|
||||||
|
|
||||||
|
// Check token configuration
|
||||||
|
const tokenConfigured = Boolean(process.env.WA_SERVER_TOKEN);
|
||||||
|
const isPlaceholder =
|
||||||
|
process.env.WA_SERVER_TOKEN === "your_whatsapp_server_token";
|
||||||
|
|
||||||
|
const healthCheck = {
|
||||||
|
service: "OTP WhatsApp",
|
||||||
|
status: "ok" as "ok" | "degraded" | "unavailable",
|
||||||
|
token: {
|
||||||
|
configured: tokenConfigured,
|
||||||
|
isPlaceholder,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If test mode, actually try to send OTP
|
||||||
|
if (isTest) {
|
||||||
|
if (!testNumber) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "Nomor diperlukan untuk test mode",
|
||||||
|
error: "Tambahkan query parameter: ?number=6281234567890",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testOtp = randomOTP();
|
||||||
|
|
||||||
|
console.log(`🧪 OTP HEALTH CHECK - Testing with number: ${testNumber}`);
|
||||||
|
console.log(`🧪 Test OTP code: ${testOtp}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const waResponse = await sendCodeOtp({
|
||||||
|
nomor: testNumber,
|
||||||
|
codeOtp: testOtp,
|
||||||
|
});
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (!waResponse.ok) {
|
||||||
|
healthCheck.status = "unavailable";
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: "Gagal mengirim OTP test",
|
||||||
|
health: healthCheck,
|
||||||
|
test: {
|
||||||
|
number: testNumber,
|
||||||
|
httpStatus: waResponse.status,
|
||||||
|
statusText: waResponse.statusText,
|
||||||
|
responseTime: `${responseTime}ms`,
|
||||||
|
note: "Cek apakah WA_SERVER_TOKEN sudah benar di Portainer",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await waResponse.json();
|
||||||
|
|
||||||
|
if (responseData.status !== "success") {
|
||||||
|
healthCheck.status = "degraded";
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: "OTP test terkirim tapi service merespon error",
|
||||||
|
health: healthCheck,
|
||||||
|
test: {
|
||||||
|
number: testNumber,
|
||||||
|
httpStatus: waResponse.status,
|
||||||
|
responseTime: `${responseTime}ms`,
|
||||||
|
serviceResponse: responseData,
|
||||||
|
note: "Cek apakah WA_SERVER_TOKEN sudah benar di Portainer",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
healthCheck.status = "ok";
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "OTP test berhasil dikirim",
|
||||||
|
health: healthCheck,
|
||||||
|
test: {
|
||||||
|
number: testNumber,
|
||||||
|
httpStatus: waResponse.status,
|
||||||
|
responseTime: `${responseTime}ms`,
|
||||||
|
serviceResponse: responseData,
|
||||||
|
otpCode: testOtp,
|
||||||
|
note: "Gunakan kode ini untuk verifikasi test",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
healthCheck.status = "unavailable";
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: "Exception saat mengirim OTP test",
|
||||||
|
health: healthCheck,
|
||||||
|
test: {
|
||||||
|
number: testNumber,
|
||||||
|
error: errorMessage,
|
||||||
|
note: "Cek network connectivity ke otp.wibudev.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic health check without sending OTP
|
||||||
|
if (!tokenConfigured || isPlaceholder) {
|
||||||
|
healthCheck.status = "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
healthCheck.status === "ok"
|
||||||
|
? "OTP service tersedia"
|
||||||
|
: "OTP service tidak tersedia - cek WA_SERVER_TOKEN",
|
||||||
|
health: healthCheck,
|
||||||
|
usage: "Tambahkan ?test=true&number=6281234567890 untuk test kirim OTP",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ OTP Health Check Error:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "Health check gagal",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,6 @@ const isNationalHoliday = (date: string): boolean => {
|
|||||||
|
|
||||||
// Hari libur pengganti
|
// Hari libur pengganti
|
||||||
'2026-04-08', // Hari Libur Pengganti Idul Fitri
|
'2026-04-08', // Hari Libur Pengganti Idul Fitri
|
||||||
'2026-04-09', // Hari Libur Pengganti Idul Fitri
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return holidays.includes(date);
|
return holidays.includes(date);
|
||||||
|
|||||||
@@ -29,16 +29,18 @@ process.on('unhandledRejection', async (error) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
process.on('SIGINT', async () => {
|
if (process.env.NODE_ENV === 'production' && !process.env.NEXT_PHASE) {
|
||||||
console.log('Received SIGINT signal. Closing database connections...');
|
process.on('SIGINT', async () => {
|
||||||
await prisma.$disconnect();
|
console.log('Received SIGINT signal. Closing database connections...');
|
||||||
process.exit(0);
|
await prisma.$disconnect();
|
||||||
});
|
// Allow natural exit
|
||||||
|
});
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
process.on('SIGTERM', async () => {
|
||||||
console.log('Received SIGTERM signal. Closing database connections...');
|
console.log('Received SIGTERM signal. Closing database connections...');
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
process.exit(0);
|
// Allow natural exit
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default prisma;
|
export default prisma;
|
||||||
Reference in New Issue
Block a user