mobile download
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 모바일 앱 설치 — APK 다운로드 URL·QR 코드
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import { loadMobileAppReleaseInfo, type MobileAppReleaseInfoDto } from '../utils/backendApi';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(n?: number): string {
|
||||
if (n == null || !Number.isFinite(n)) return '—';
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** APK·QR은 항상 서버가 내려준 절대 URL 사용 (exdev.co.kr, localhost 아님) */
|
||||
function resolveDownloadUrl(info: MobileAppReleaseInfoDto | null): string {
|
||||
if (!info?.available) return '';
|
||||
return info.downloadUrl?.trim() ?? '';
|
||||
}
|
||||
|
||||
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const downloadUrl = useMemo(() => resolveDownloadUrl(info), [info]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadMobileAppReleaseInfo()
|
||||
.then(data => {
|
||||
if (!cancelled) setInfo(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('앱 정보를 불러오지 못했습니다.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadUrl) {
|
||||
setQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void QRCode.toDataURL(downloadUrl, {
|
||||
width: 220,
|
||||
margin: 2,
|
||||
color: { dark: '#111827', light: '#ffffff' },
|
||||
}).then(url => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setQrDataUrl(null);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [downloadUrl]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!downloadUrl) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = info?.fileName ?? 'goldenchart-android.apk';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableModalFrame
|
||||
onClose={onClose}
|
||||
title="Mobile App"
|
||||
titleKo="GoldenChart 앱 설치"
|
||||
badge="Android"
|
||||
width={440}
|
||||
dialogClassName="sp-modal app-download-modal"
|
||||
>
|
||||
<div className="app-download-body">
|
||||
{loading && <p className="app-download-muted">앱 정보 불러오는 중…</p>}
|
||||
{!loading && error && <p className="app-download-error">{error}</p>}
|
||||
{!loading && !error && !info?.available && (
|
||||
<div className="app-download-empty">
|
||||
<div className="app-download-empty-icon" aria-hidden>📱</div>
|
||||
<h3>설치 파일 준비 중</h3>
|
||||
<p>
|
||||
Android APK가 아직 서버에 업로드되지 않았습니다.
|
||||
<br />
|
||||
<code>data/mobile-releases/goldenchart-android.apk</code> 경로에 빌드 파일을 배치하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && info?.available && (
|
||||
<>
|
||||
<div className="app-download-hero">
|
||||
<div className="app-download-app-icon" aria-hidden>GC</div>
|
||||
<div>
|
||||
<h3 className="app-download-app-name">GoldenChart</h3>
|
||||
<p className="app-download-meta">
|
||||
{info.version && <span>v{info.version}</span>}
|
||||
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
|
||||
{info.updatedAt && <span>{info.updatedAt}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-download-qr-block">
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
|
||||
) : (
|
||||
<div className="app-download-qr app-download-qr--placeholder">QR 생성 중…</div>
|
||||
)}
|
||||
<div className="app-download-qr-help">
|
||||
<strong>휴대폰 카메라로 QR 스캔</strong>
|
||||
<p>Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.</p>
|
||||
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="app-download-btn-primary" onClick={handleDownload}>
|
||||
Android APK 다운로드
|
||||
</button>
|
||||
|
||||
<details className="app-download-steps">
|
||||
<summary>설치 방법 (Android)</summary>
|
||||
<ol>
|
||||
<li>QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.</li>
|
||||
<li>「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.</li>
|
||||
<li>다운로드 완료 후 APK 파일을 탭하여 설치합니다.</li>
|
||||
<li>설치 후 앱에서 API 서버 URL을 설정하세요 (설정 › 네트워크).</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<p className="app-download-ios-note">
|
||||
iOS는 App Store / TestFlight 배포가 필요합니다. 현재 QR·APK 설치는 Android 전용입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppDownloadModal;
|
||||
Reference in New Issue
Block a user