pc 프로그램 다운로드 기능 적용

This commit is contained in:
Macbook
2026-06-14 10:25:22 +09:00
parent 86b99d8c10
commit 92f67580e3
25 changed files with 1031 additions and 125 deletions
+232 -91
View File
@@ -1,28 +1,45 @@
/**
* 모바일 앱 설치 — APK 다운로드 URL·QR 코드
* 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe)
*/
import React, { useEffect, useMemo, useState } from 'react';
import QRCode from 'qrcode';
import DraggableModalFrame from './DraggableModalFrame';
import { loadMobileAppReleaseInfo, type MobileAppReleaseInfoDto } from '../utils/backendApi';
import {
loadDesktopAppReleaseInfo,
loadMobileAppReleaseInfo,
type DesktopAppPlatformReleaseDto,
type DesktopAppReleaseInfoDto,
type MobileAppReleaseInfoDto,
} from '../utils/backendApi';
interface Props {
onClose: () => void;
}
type TabId = 'mobile' | 'desktop';
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 }) => {
function triggerDownload(url: string, fileName: string) {
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
const MobileTab: React.FC = () => {
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
const [loading, setLoading] = useState(true);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
@@ -33,15 +50,9 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
useEffect(() => {
let cancelled = false;
void loadMobileAppReleaseInfo()
.then(data => {
if (!cancelled) setInfo(data);
})
.catch(() => {
if (!cancelled) setError('앱 정보를 불러오지 못했습니다.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
.then(data => { if (!cancelled) setInfo(data); })
.catch(() => { if (!cancelled) setError('앱 정보를 불러오지 못했습니다.'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
@@ -55,94 +66,224 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
width: 220,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
}).then(url => {
if (!cancelled) setQrDataUrl(url);
}).catch(() => {
if (!cancelled) setQrDataUrl(null);
});
}).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();
};
if (loading) return <p className="app-download-muted"> </p>;
if (error) return <p className="app-download-error">{error}</p>;
if (!info?.available) {
return (
<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>
);
}
return (
<>
<div className="app-download-hero">
<div className="app-download-app-icon" aria-hidden>GC</div>
<div>
<h3 className="app-download-app-name">GoldenChart Mobile</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={() => triggerDownload(downloadUrl, info.fileName ?? 'goldenchart-android.apk')}
>
Android APK
</button>
<details className="app-download-steps">
<summary> (Android)</summary>
<ol>
<li>QR APK를 .</li>
<li> .</li>
<li> APK .</li>
</ol>
</details>
<p className="app-download-ios-note">
iOS는 App Store / TestFlight . QR·APK Android .
</p>
</>
);
};
const PlatformCard: React.FC<{
platform: 'mac' | 'windows';
release?: DesktopAppPlatformReleaseDto;
}> = ({ platform, release }) => {
const isMac = platform === 'mac';
const label = isMac ? 'macOS (Apple Silicon · Intel)' : 'Windows (64-bit)';
const icon = isMac ? '🍎' : '🪟';
const ext = isMac ? '.dmg' : '.exe';
if (!release?.available || !release.downloadUrl) {
return (
<div className="app-download-pc-card app-download-pc-card--empty">
<div className="app-download-pc-icon" aria-hidden>{icon}</div>
<div>
<strong>{label}</strong>
<p className="app-download-muted"> ({ext})</p>
</div>
</div>
);
}
return (
<div className="app-download-pc-card">
<div className="app-download-pc-icon" aria-hidden>{icon}</div>
<div className="app-download-pc-body">
<strong>{label}</strong>
<p className="app-download-meta">
{release.version && <span>v{release.version}</span>}
{release.sizeBytes != null && <span>{formatBytes(release.sizeBytes)}</span>}
{release.updatedAt && <span>{release.updatedAt}</span>}
</p>
<p className="app-download-muted app-download-url" title={release.downloadUrl}>
{release.fileName}
</p>
</div>
<button
type="button"
className="app-download-pc-btn"
onClick={() => triggerDownload(release.downloadUrl!, release.fileName ?? `GoldenChart${ext}`)}
>
</button>
</div>
);
};
const DesktopTab: React.FC = () => {
const [info, setInfo] = useState<DesktopAppReleaseInfoDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void loadDesktopAppReleaseInfo()
.then(data => { if (!cancelled) setInfo(data); })
.catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const hasAny = info?.mac?.available || info?.windows?.available;
if (loading) return <p className="app-download-muted">PC </p>;
if (error) return <p className="app-download-error">{error}</p>;
return (
<>
<div className="app-download-hero">
<div className="app-download-app-icon app-download-app-icon--desktop" aria-hidden>GC</div>
<div>
<h3 className="app-download-app-name">GoldenChart Desktop</h3>
<p className="app-download-meta">
{info?.version && <span>v{info.version}</span>}
{info?.updatedAt && <span>{info.updatedAt}</span>}
</p>
</div>
</div>
{!hasAny && (
<div className="app-download-empty">
<div className="app-download-empty-icon" aria-hidden>💻</div>
<h3> </h3>
<p>
macOS(.dmg) Windows(.exe) .
<br />
Jenkins <code>scripts/publish-desktop-update.sh</code> .
</p>
</div>
)}
{hasAny && (
<>
<div className="app-download-pc-list">
<PlatformCard platform="mac" release={info?.mac} />
<PlatformCard platform="windows" release={info?.windows} />
</div>
<details className="app-download-steps">
<summary> </summary>
<ol>
<li><strong>macOS</strong>: dmg를 GoldenChart.app을 Applications .</li>
<li><strong>Windows</strong>: setup.exe를 .</li>
<li> PC <strong></strong> .</li>
</ol>
</details>
</>
)}
</>
);
};
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
const [tab, setTab] = useState<TabId>('mobile');
return (
<DraggableModalFrame
onClose={onClose}
title="Mobile App"
titleKo="GoldenChart 앱 설치"
badge="Android"
width={440}
title="Apps"
titleKo="GoldenChart 앱·PC 프로그램"
badge={tab === 'mobile' ? 'Mobile' : 'Desktop'}
width={480}
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-tabs" role="tablist" aria-label="다운로드 종류">
<button
type="button"
role="tab"
aria-selected={tab === 'mobile'}
className={`app-download-tab${tab === 'mobile' ? ' active' : ''}`}
onClick={() => setTab('mobile')}
>
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'desktop'}
className={`app-download-tab${tab === 'desktop' ? ' active' : ''}`}
onClick={() => setTab('desktop')}
>
PC
</button>
</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 className="app-download-body" role="tabpanel">
{tab === 'mobile' ? <MobileTab /> : <DesktopTab />}
</div>
</DraggableModalFrame>
);