Files
altdock/components/altdock/dashboard-view.tsx
T
amania-jailbreak 93825ebc64 初回コミット: AltStore PAL向けADPホスティングSaaS
- Fastify API・Node Worker・PostgreSQL・S3互換ストレージ構成
- ADP ZIPのマルチパートアップロードとManifest検証(パストラバーサル・ZIP爆弾等を拒否)
- manifest.json/signatureは再シリアライズせず元バイト列を保持
- Cloudflare風の運用向け管理ダッシュボード(shadcn/Radix・日本語UI・4ルート)
  概要/Sources/アプリ/リリース + ルートランディングページ
- 汎用OIDC SSO・Workspace単位の認可・demo mode
- 匿名配布: /sources/{slug}/source.json, /artifacts/{releaseId}/manifest.json
- Docker Compose対応
2026-08-05 10:09:58 +09:00

160 lines
34 KiB
TypeScript

"use client";
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Activity, ArrowUpRight, ChevronRight, Clipboard, Code2, Copy, Database, ExternalLink, Filter, HardDrive, Pencil, Plus, RefreshCw, Search, ShieldCheck, UploadCloud, XCircle, type LucideIcon } from "lucide-react";
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
import { API_BASE, flattenApps, flattenReleases, formatBytes, formatDate, formatDateTime, statusLabel, statusTone, visibilityLabel, visibilityTone, type DashboardData, type DashboardSource, type Visibility } from "@/app/lib/dashboard";
import { useDashboard } from "./dashboard-provider";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogForm, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type ViewName = "overview" | "sources" | "apps" | "releases";
function PageHeading({ eyebrow, title, description, actions }: { eyebrow?: string; title: string; description: string; actions?: ReactNode }) {
return <div className="admin-page-heading"><div><div className="admin-eyebrow">{eyebrow || "ALTDock WORKSPACE"}</div><h1>{title}</h1><p>{description}</p></div><div className="admin-page-actions">{actions}</div></div>;
}
function MetricCard({ label, value, detail, icon: Icon, tone = "neutral" }: { label: string; value: string | number; detail: string; icon: LucideIcon; tone?: "neutral" | "orange" | "blue" | "green" }) {
return <article className={`admin-metric-card metric-${tone}`}><div className="admin-metric-top"><span>{label}</span><Icon size={16} strokeWidth={1.8} /></div><strong>{value}</strong><small>{detail}</small></article>;
}
function SectionCard({ title, description, action, children, className = "" }: { title: string; description?: string; action?: ReactNode; children: ReactNode; className?: string }) {
return <section className={`admin-section-card ${className}`}><div className="admin-section-card-header"><div><h2>{title}</h2>{description && <p>{description}</p>}</div>{action}</div>{children}</section>;
}
function EmptyState({ icon: Icon, title, description, action }: { icon: LucideIcon; title: string; description: string; action?: ReactNode }) {
return <div className="admin-empty-state"><span className="admin-empty-icon"><Icon size={20} /></span><strong>{title}</strong><p>{description}</p>{action}</div>;
}
function LoadingState() {
return <div className="admin-loading-state"><span className="admin-spinner" /><strong>Workspaceを読み込んでいます</strong><p>APIから最新の配布状況を取得中です</p></div>;
}
function InlineStatus({ status }: { status: ReleaseRecord["status"] }) {
return <Badge tone={statusTone(status)}><span className="admin-status-dot-small" />{statusLabel(status)}</Badge>;
}
function InlineVisibility({ visibility }: { visibility: Visibility }) {
return <Badge tone={visibilityTone(visibility)}><span className="admin-status-dot-small" />{visibilityLabel(visibility)}</Badge>;
}
function SourceDialog({ open, onOpenChange, source, busy, onSubmit }: { open: boolean; onOpenChange: (open: boolean) => void; source?: SourceRecord; busy: boolean; onSubmit: (input: { name: string; subtitle: string; description: string; visibility: Visibility }) => Promise<void> }) {
const [form, setForm] = useState({ name: source?.name || "", subtitle: source?.subtitle || "", description: source?.description || "", visibility: source?.visibility || "draft" as Visibility });
const submit = async (event: FormEvent) => { event.preventDefault(); await onSubmit(form); onOpenChange(false); };
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><div className="dialog-kicker">{source ? "SOURCE SETTINGS" : "NEW SOURCE"}</div><DialogTitle>{source ? "Sourceを編集" : "Sourceを作成"}</DialogTitle><DialogDescription>AltStore PALに表示する配布面の基本情報を設定します</DialogDescription></DialogHeader><DialogForm onSubmit={submit}><label className="admin-field">Source名<input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="My Apps" /></label><label className="admin-field">Subtitle<input value={form.subtitle} onChange={(event) => setForm({ ...form, subtitle: event.target.value })} placeholder="iOSアプリの配布ページ" /></label><label className="admin-field">説明<textarea rows={4} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="このSourceについて説明します。" /></label><label className="admin-field">公開状態<select value={form.visibility} onChange={(event) => setForm({ ...form, visibility: event.target.value as Visibility })}><option value="draft">下書き 外部から取得不可</option><option value="unlisted">限定公開 URLを知っている人だけ</option><option value="public">公開 公開ページで案内</option></select></label><DialogFooter><Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>キャンセル</Button><Button disabled={busy}>{busy ? "保存中…" : source ? "変更を保存" : "Sourceを作成"}</Button></DialogFooter></DialogForm></DialogContent></Dialog>;
}
function UploadDialog({ open, onOpenChange, sources, busy, maxUploadBytes, onSubmit }: { open: boolean; onOpenChange: (open: boolean) => void; sources: DashboardSource[]; busy: boolean; maxUploadBytes: number; onSubmit: (sourceId: string, file: File, appId?: string) => Promise<void> }) {
const firstSource = sources[0];
const [sourceId, setSourceId] = useState(firstSource?.source.id || "");
const [appId, setAppId] = useState("");
const [file, setFile] = useState<File | null>(null);
const selectedSource = sources.find((entry) => entry.source.id === sourceId) || firstSource;
const submit = async (event: FormEvent) => { event.preventDefault(); if (!selectedSource || !file) return; await onSubmit(selectedSource.source.id, file, appId || undefined); setFile(null); setAppId(""); onOpenChange(false); };
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><div className="dialog-kicker">NEW RELEASE</div><DialogTitle>ADPをアップロード</DialogTitle><DialogDescription>Notarization済みのADP ZIPをそのまま選択してくださいManifestとsignatureは書き換えません</DialogDescription></DialogHeader><DialogForm onSubmit={submit}><label className="admin-field">アップロード先のSource<select value={sourceId} onChange={(event) => { setSourceId(event.target.value); setAppId(""); }}>{sources.map((entry) => <option key={entry.source.id} value={entry.source.id}>{entry.source.name}</option>)}</select></label>{selectedSource && selectedSource.apps.length > 0 && <label className="admin-field">対象アプリ<span className="admin-field-hint">空欄ならManifestから新しいアプリを作成</span><select value={appId} onChange={(event) => setAppId(event.target.value)}><option value="">新しいアプリ</option>{selectedSource.apps.map((app) => <option key={app.id} value={app.id}>{app.name} · {app.bundleIdentifier}</option>)}</select></label>}<div className="admin-dropzone"><input id="adp-file-new" type="file" accept=".zip,application/zip" onChange={(event) => setFile(event.target.files?.[0] || null)} /><label htmlFor="adp-file-new"><span className="admin-dropzone-icon"><UploadCloud size={22} /></span><strong>{file?.name || "ADP ZIPを選択"}</strong><small>{file ? formatBytes(file.size) : `最大 ${formatBytes(maxUploadBytes)} · manifest.json + signature required`}</small></label></div><div className="admin-info-callout"><ShieldCheck size={16} /><span>パストラバーサル、シンボリックリンク、ZIP爆弾、欠落・余分なファイルをWorkerで検査します</span></div><DialogFooter><Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>キャンセル</Button><Button disabled={!file || !selectedSource || busy}>{busy ? "処理中…" : "アップロードして検証"}</Button></DialogFooter></DialogForm></DialogContent></Dialog>;
}
function AppDialog({ open, onOpenChange, app, busy, onSubmit }: { open: boolean; onOpenChange: (open: boolean) => void; app: AppRecord | null; busy: boolean; onSubmit: (id: string, patch: Partial<AppRecord>) => Promise<void> }) {
const [form, setForm] = useState({ name: app?.name || "", developerName: app?.developerName || "", subtitle: app?.subtitle || "", localizedDescription: app?.localizedDescription || "", iconURL: app?.iconURL || "", category: app?.category || "other", entitlements: app?.appPermissions?.entitlements.join("\n") || "", privacy: JSON.stringify(app?.appPermissions?.privacy || {}, null, 2) });
const [error, setError] = useState("");
if (!app) return null;
const submit = async (event: FormEvent) => { event.preventDefault(); setError(""); try { const privacy = JSON.parse(form.privacy) as Record<string, string>; await onSubmit(app.id, { name: form.name, developerName: form.developerName, subtitle: form.subtitle, localizedDescription: form.localizedDescription, iconURL: form.iconURL || undefined, category: form.category, appPermissions: { entitlements: form.entitlements.split("\n").map((value) => value.trim()).filter(Boolean), privacy } }); onOpenChange(false); } catch (reason) { setError(reason instanceof Error ? reason.message : "JSON形式を確認してください。"); } };
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="wide-dialog"><DialogHeader><div className="dialog-kicker">APP METADATA</div><DialogTitle>アプリ情報を編集</DialogTitle><DialogDescription>Source JSONに出力する表示情報と権限情報を確認します</DialogDescription></DialogHeader><DialogForm onSubmit={submit}><div className="admin-form-grid"><label className="admin-field">アプリ名<input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></label><label className="admin-field">Developer<input value={form.developerName} onChange={(event) => setForm({ ...form, developerName: event.target.value })} /></label></div><div className="admin-form-grid"><label className="admin-field">Subtitle<input value={form.subtitle} onChange={(event) => setForm({ ...form, subtitle: event.target.value })} /></label><label className="admin-field">カテゴリ<select value={form.category} onChange={(event) => setForm({ ...form, category: event.target.value })}><option value="developer">Developer</option><option value="entertainment">Entertainment</option><option value="games">Games</option><option value="lifestyle">Lifestyle</option><option value="other">Other</option><option value="photo-video">Photo & Video</option><option value="social">Social</option><option value="utilities">Utilities</option></select></label></div><label className="admin-field">説明<textarea rows={4} value={form.localizedDescription} onChange={(event) => setForm({ ...form, localizedDescription: event.target.value })} /></label><label className="admin-field">Icon URL<input type="url" value={form.iconURL} onChange={(event) => setForm({ ...form, iconURL: event.target.value })} placeholder="https://…" /></label><div className="admin-form-grid"><label className="admin-field">Bundle ID<input readOnly value={app.bundleIdentifier} /></label><label className="admin-field">Apple Item ID<input readOnly value={app.marketplaceID || "—"} /></label></div><label className="admin-field">Entitlements<span className="admin-field-hint">1行に1</span><textarea rows={3} value={form.entitlements} onChange={(event) => setForm({ ...form, entitlements: event.target.value })} /></label><label className="admin-field">Privacy UsageDescription JSON<textarea rows={4} value={form.privacy} onChange={(event) => setForm({ ...form, privacy: event.target.value })} /></label>{error && <div className="admin-form-error">{error}</div>}<DialogFooter><Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>キャンセル</Button><Button disabled={busy}>{busy ? "保存中…" : "変更を保存"}</Button></DialogFooter></DialogForm></DialogContent></Dialog>;
}
function JsonPreviewDialog({ source, open, onOpenChange }: { source: SourceRecord | null; open: boolean; onOpenChange: (open: boolean) => void }) {
const [payload, setPayload] = useState<string>("");
const [error, setError] = useState("");
useEffect(() => {
if (!open || !source) return;
setPayload(""); setError("");
fetch(`${API_BASE}/sources/${source.slug}/source.json`).then(async (response) => { if (!response.ok) throw new Error("公開済みSourceだけがJSONを取得できます。"); return response.json(); }).then((value) => setPayload(JSON.stringify(value, null, 2))).catch((reason) => setError(reason instanceof Error ? reason.message : "Source JSONを取得できませんでした。"));
}, [open, source]);
if (!source) return null;
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="wide-dialog"><DialogHeader><div className="dialog-kicker">SOURCE JSON</div><DialogTitle>{source.name}</DialogTitle><DialogDescription>{source.visibility === "draft" ? "下書きのため匿名Source JSONはまだ取得できません。" : `${API_BASE}/sources/${source.slug}/source.json`}</DialogDescription></DialogHeader><Tabs defaultValue="summary"><TabsList><TabsTrigger value="summary">概要</TabsTrigger><TabsTrigger value="json">JSONプレビュー</TabsTrigger></TabsList><TabsContent value="summary"><div className="json-summary-grid"><div><span>公開状態</span><strong><InlineVisibility visibility={source.visibility} /></strong></div><div><span>Source slug</span><code>{source.slug}</code></div><div><span>公開URL</span><code>{`${API_BASE}/sources/${source.slug}/source.json`}</code></div></div></TabsContent><TabsContent value="json">{error ? <div className="admin-form-error">{error}</div> : payload ? <pre className="json-preview">{payload}</pre> : <div className="admin-loading-inline"><span className="admin-spinner" />JSONを取得しています</div>}</TabsContent></Tabs><DialogFooter><Button variant="ghost" onClick={() => onOpenChange(false)}>閉じる</Button></DialogFooter></DialogContent></Dialog>;
}
function ReleaseDetailsDialog({ item, open, onOpenChange }: { item: { release: ReleaseRecord; app?: AppRecord; source: SourceRecord } | null; open: boolean; onOpenChange: (open: boolean) => void }) {
if (!item) return null;
const { release, app, source } = item;
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="wide-dialog"><DialogHeader><div className="dialog-kicker">RELEASE DETAILS</div><DialogTitle>{app?.name || "アプリリリース"} <span className="admin-title-muted">v{release.version}</span></DialogTitle><DialogDescription>{source.name} · build {release.buildVersion} · {formatDateTime(release.date)}</DialogDescription></DialogHeader><div className="release-detail-grid"><div><span>状態</span><strong><InlineStatus status={release.status} /></strong></div><div><span>容量</span><strong>{formatBytes(release.sizeBytes)}</strong></div><div><span>Bundle ID</span><code>{release.manifest.bundleId}</code></div><div><span>Apple Item ID</span><code>{release.manifest.appleItemId}</code></div><div><span>最低OS</span><strong>{release.minOSVersion || "—"}</strong></div><div><span>公開日</span><strong>{release.publishedAt ? formatDateTime(release.publishedAt) : "未公開"}</strong></div></div>{release.errorMessage && <div className="admin-form-error">{release.errorMessage}</div>}<div className="asset-path-panel"><h3>Manifest参照パス</h3><p>元のManifestに記載されたassetPath / sourcePath / deltaPathを確認できます</p>{release.manifest.variantPaths.length || release.manifest.deltaPaths.length ? <ul>{[...release.manifest.variantPaths.map((path) => `variant · ${path}`), ...release.manifest.deltaPaths.map((path) => `delta · ${path}`)].map((path) => <li key={path}><code>{path}</code></li>)}</ul> : <div className="admin-table-empty">参照パスはありません。</div>}</div><DialogFooter><Button variant="ghost" onClick={() => onOpenChange(false)}>閉じる</Button></DialogFooter></DialogContent></Dialog>;
}
function Overview({ dashboard, onCreateSource, onUpload, onOpenSource, onOpenRelease }: { dashboard: DashboardData; onCreateSource: () => void; onUpload: () => void; onOpenSource: (source: SourceRecord) => void; onOpenRelease: (item: { release: ReleaseRecord; app?: AppRecord; source: SourceRecord }) => void }) {
const flattened = flattenReleases(dashboard).sort((a, b) => b.release.date.localeCompare(a.release.date));
const appCount = flattenApps(dashboard).length;
const published = flattened.filter((item) => item.release.status === "published").length;
const processing = flattened.filter((item) => item.release.status === "processing").length;
const needsAttention = flattened.filter((item) => item.release.status === "rejected").length;
const usagePercent = Math.min(100, dashboard.usage.storageBytes / Math.max(1, dashboard.limits.maxStorageBytes) * 100);
return <>
<PageHeading eyebrow="WORKSPACE OVERVIEW" title="概要" description="Source、アプリ、リリースの現在の状態を確認します。" actions={<><Button variant="outline" onClick={onUpload}><UploadCloud size={15} />ADPをアップロード</Button><Button onClick={onCreateSource}><Plus size={15} />Sourceを作成</Button></>} />
<div className="admin-metric-grid"><MetricCard label="SOURCES" value={dashboard.usage.sourceCount} detail={`${dashboard.limits.maxSources}件中`} icon={Database} tone="orange" /><MetricCard label="アプリ" value={appCount} detail={`${published}件の公開リリース`} icon={Code2} tone="blue" /><MetricCard label="保存容量" value={formatBytes(dashboard.usage.storageBytes)} detail={`${formatBytes(dashboard.limits.maxStorageBytes)}まで`} icon={HardDrive} tone="green" /><MetricCard label="要確認" value={needsAttention} detail={processing ? `${processing}件を処理中` : "現在なし"} icon={Activity} tone={needsAttention ? "orange" : "neutral"} /></div>
<div className="admin-overview-grid"><SectionCard title="配布状態" description="Workspace全体のリリース状態"><div className="health-list"><div><span className="health-label"><i className="health-dot success" />公開中</span><strong>{published}</strong></div><div><span className="health-label"><i className="health-dot info" />処理中</span><strong>{processing}</strong></div><div><span className="health-label"><i className="health-dot warning" />公開準備完了</span><strong>{flattened.filter((item) => item.release.status === "ready").length}</strong></div><div><span className="health-label"><i className="health-dot danger" />要修正</span><strong>{needsAttention}</strong></div></div><div className="quota-summary"><div><span>Storage quota</span><strong>{usagePercent.toFixed(0)}%</strong></div><div className="admin-progress"><span style={{ width: `${usagePercent}%` }} /></div></div></SectionCard><SectionCard title="クイック操作" description="よく使う管理操作"><button className="admin-action-row" onClick={onUpload}><span className="admin-action-icon orange"><UploadCloud size={17} /></span><span><strong>ADPをアップロード</strong><small>Manifestを検証してリリースを作成</small></span><ChevronRight size={16} /></button><button className="admin-action-row" onClick={onCreateSource}><span className="admin-action-icon blue"><Plus size={17} /></span><span><strong>Sourceを作成</strong><small>PALへ公開する配布面を追加</small></span><ChevronRight size={16} /></button><a className="admin-action-row" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer"><span className="admin-action-icon neutral"><Clipboard size={17} /></span><span><strong>PALの配布要件を確認</strong><small>AltStore公式ドキュメント</small></span><ExternalLink size={15} /></a></SectionCard></div>
<SectionCard title="Sources" description="公開面ごとの状態とアプリ数" action={<a className="admin-link" href="/dashboard/sources">すべて表示 <ArrowUpRight size={14} /></a>}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Source</th><th>状態</th><th>アプリ</th><th>更新日</th><th /></tr></thead><tbody>{dashboard.sources.length ? dashboard.sources.map((entry) => <tr key={entry.source.id}><td><button className="table-primary-link" onClick={() => onOpenSource(entry.source)}><span className="table-avatar" style={{ background: entry.source.tintColor }}>{entry.source.name.slice(0, 1).toUpperCase()}</span><span><strong>{entry.source.name}</strong><small>{entry.source.slug}</small></span></button></td><td><InlineVisibility visibility={entry.source.visibility} /></td><td>{entry.apps.length}</td><td>{formatDate(entry.source.updatedAt)}</td><td><ChevronRight size={15} className="table-chevron" /></td></tr>) : <tr><td colSpan={5}><EmptyState icon={Boxes} title="Sourceがありません" description="最初の配布面を作成してください。" action={<Button size="sm" onClick={onCreateSource}>Sourceを作成</Button>} /></td></tr>}</tbody></table></div></SectionCard>
<SectionCard title="最近のリリース" description="最新5件の処理結果" action={<a className="admin-link" href="/dashboard/releases">リリース一覧 <ArrowUpRight size={14} /></a>}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>アプリ / Version</th><th>Source</th><th>状態</th><th>容量</th><th>日付</th></tr></thead><tbody>{flattened.slice(0, 5).map((item) => <tr key={item.release.id} onClick={() => onOpenRelease(item)} className="admin-clickable-row"><td><span className="table-primary-link"><span className="table-avatar table-avatar-small" style={{ background: item.app?.tintColor || item.source.tintColor }}>{item.app?.name.slice(0, 1).toUpperCase() || "A"}</span><span><strong>{item.app?.name || "App release"}</strong><small>v{item.release.version} · build {item.release.buildVersion}</small></span></span></td><td>{item.source.name}</td><td><InlineStatus status={item.release.status} /></td><td>{formatBytes(item.release.sizeBytes)}</td><td>{formatDate(item.release.date)}</td></tr>)}{!flattened.length && <tr><td colSpan={5}><div className="admin-table-empty">アップロードされたリリースはここに表示されます。</div></td></tr>}</tbody></table></div></SectionCard>
</>;
}
function SourcesView({ dashboard, onCreateSource, onEditSource, onOpenJson, onCopyUrl }: { dashboard: DashboardData; onCreateSource: () => void; onEditSource: (source: SourceRecord) => void; onOpenJson: (source: SourceRecord) => void; onCopyUrl: (source: SourceRecord) => void }) {
const [query, setQuery] = useState("");
const rows = dashboard.sources.filter((entry) => `${entry.source.name} ${entry.source.slug} ${entry.source.subtitle}`.toLowerCase().includes(query.toLowerCase()));
return <><PageHeading eyebrow="DISTRIBUTION SURFACES" title="Sources" description="AltStore PALへ公開する配布面を管理します。" actions={<Button onClick={onCreateSource}><Plus size={15} />Sourceを作成</Button>} /><SectionCard title="Source一覧" description={`${rows.length} / ${dashboard.sources.length}件`}><div className="admin-toolbar"><label className="admin-search-field"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Source名・slugを検索" /></label></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Source</th><th>公開状態</th><th>アプリ</th><th>最終更新</th><th>操作</th></tr></thead><tbody>{rows.map((entry) => <tr key={entry.source.id}><td><div className="table-primary-link"><span className="table-avatar" style={{ background: entry.source.tintColor }}>{entry.source.name.slice(0, 1).toUpperCase()}</span><span><strong>{entry.source.name}</strong><small>{entry.source.subtitle || entry.source.slug}</small></span></div></td><td><InlineVisibility visibility={entry.source.visibility} /></td><td>{entry.apps.length}</td><td>{formatDate(entry.source.updatedAt)}</td><td><div className="table-actions"><Button size="icon" variant="ghost" onClick={() => onOpenJson(entry.source)} aria-label={`${entry.source.name}のJSONをプレビュー`}><Code2 size={15} /></Button><Button size="icon" variant="ghost" onClick={() => onCopyUrl(entry.source)} aria-label={`${entry.source.name}のURLをコピー`}><Copy size={15} /></Button><Button size="icon" variant="ghost" onClick={() => onEditSource(entry.source)} aria-label={`${entry.source.name}を編集`}><Pencil size={15} /></Button></div></td></tr>)}{!rows.length && <tr><td colSpan={5}><div className="admin-table-empty">一致するSourceがありません</div></td></tr>}</tbody></table></div></SectionCard></>;
}
function AppsView({ dashboard, onEditApp }: { dashboard: DashboardData; onEditApp: (app: AppRecord) => void }) {
const [query, setQuery] = useState("");
const rows = flattenApps(dashboard).filter(({ app, source }) => `${app.name} ${app.bundleIdentifier} ${source.name}`.toLowerCase().includes(query.toLowerCase()));
return <><PageHeading eyebrow="APP CATALOG" title="アプリ" description="Manifestから抽出した識別子と、Sourceに表示するメタデータを管理します。" /><SectionCard title="アプリ一覧" description={`${rows.length}件`}><div className="admin-toolbar"><label className="admin-search-field"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="アプリ名・Bundle IDを検索" /></label></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>アプリ</th><th>Bundle ID</th><th>Source</th><th>最新リリース</th><th>カテゴリ</th><th>操作</th></tr></thead><tbody>{rows.map(({ app, source, releases }) => { const latest = releases.slice().sort((a, b) => b.date.localeCompare(a.date))[0]; return <tr key={app.id}><td><div className="table-primary-link"><span className="table-avatar" style={{ background: app.tintColor || source.tintColor }}>{app.name.slice(0, 1).toUpperCase()}</span><span><strong>{app.name}</strong><small>{app.developerName || "Developer未設定"}</small></span></div></td><td><code className="table-code">{app.bundleIdentifier}</code></td><td>{source.name}</td><td>{latest ? <span className="version-cell"><strong>v{latest.version}</strong><small>build {latest.buildVersion}</small></span> : "—"}</td><td><span className="category-label">{app.category}</span></td><td><Button size="icon" variant="ghost" onClick={() => onEditApp(app)} aria-label={`${app.name}を編集`}><Pencil size={15} /></Button></td></tr>; })}{!rows.length && <tr><td colSpan={6}><EmptyState icon={Code2} title="アプリがありません" description="ADPをアップロードするとManifestからアプリが作成されます。" /></td></tr>}</tbody></table></div></SectionCard></>;
}
function ReleasesView({ dashboard, onUpload, onOpenRelease, onPublish }: { dashboard: DashboardData; onUpload: () => void; onOpenRelease: (item: { release: ReleaseRecord; app?: AppRecord; source: SourceRecord }) => void; onPublish: (id: string) => Promise<void> }) {
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const rows = flattenReleases(dashboard).filter(({ release, app, source }) => { const matchesQuery = `${app?.name || ""} ${source.name} ${release.version} ${release.buildVersion} ${release.manifest.bundleId}`.toLowerCase().includes(query.toLowerCase()); return matchesQuery && (statusFilter === "all" || release.status === statusFilter); }).sort((a, b) => b.release.date.localeCompare(a.release.date));
return <><PageHeading eyebrow="PACKAGE PIPELINE" title="リリース" description="ADPの検証結果、Manifest参照パス、公開状態を確認します。" actions={<Button onClick={onUpload}><UploadCloud size={15} />ADPをアップロード</Button>} /><SectionCard title="リリース一覧" description={`${rows.length}件`}><div className="admin-toolbar"><label className="admin-search-field"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="アプリ名・version・Bundle IDを検索" /></label><label className="admin-filter-field"><Filter size={14} /><select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="all">すべての状態</option><option value="processing">処理中</option><option value="ready">公開準備完了</option><option value="published">公開中</option><option value="rejected">要修正</option><option value="archived">アーカイブ</option></select></label><Button variant="outline" size="sm" onClick={() => window.location.reload()}><RefreshCw size={14} />更新</Button></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>アプリ / Version</th><th>Source</th><th>状態</th><th>Manifest</th><th>容量</th><th>日付</th><th /></tr></thead><tbody>{rows.map((item) => <tr key={item.release.id} onClick={() => onOpenRelease(item)} className="admin-clickable-row"><td><div className="table-primary-link"><span className="table-avatar table-avatar-small" style={{ background: item.app?.tintColor || item.source.tintColor }}>{item.app?.name.slice(0, 1).toUpperCase() || "A"}</span><span><strong>{item.app?.name || "App release"}</strong><small>v{item.release.version} · build {item.release.buildVersion}</small></span></div></td><td>{item.source.name}</td><td><InlineStatus status={item.release.status} /></td><td><code className="table-code">{item.release.manifest.bundleId}</code><small className="table-subline">{item.release.manifest.variantPaths.length} variant · {item.release.manifest.deltaPaths.length} delta</small></td><td>{formatBytes(item.release.sizeBytes)}</td><td>{formatDate(item.release.date)}</td><td>{item.release.status === "ready" ? <Button size="sm" onClick={(event) => { event.stopPropagation(); void onPublish(item.release.id); }}>公開</Button> : <ChevronRight size={15} className="table-chevron" />}</td></tr>)}{!rows.length && <tr><td colSpan={7}><div className="admin-table-empty">一致するリリースがありません。</div></td></tr>}</tbody></table></div></SectionCard></>;
}
export function DashboardView({ view }: { view: ViewName }) {
const { dashboard, loading, busy, createSource, updateSource, uploadRelease, saveApp, publishRelease, copySourceUrl } = useDashboard();
const router = useRouter();
const searchParams = useSearchParams();
const [sourceDialogOpen, setSourceDialogOpen] = useState(false);
const [editingSource, setEditingSource] = useState<SourceRecord | undefined>();
const [uploadOpen, setUploadOpen] = useState(false);
const [editingApp, setEditingApp] = useState<AppRecord | null>(null);
const [jsonSource, setJsonSource] = useState<SourceRecord | null>(null);
const [releaseItem, setReleaseItem] = useState<{ release: ReleaseRecord; app?: AppRecord; source: SourceRecord } | null>(null);
useEffect(() => {
if (view === "releases" && searchParams.get("upload") === "1") {
setUploadOpen(true);
router.replace("/dashboard/releases");
}
}, [router, searchParams, view]);
if (loading && !dashboard) return <LoadingState />;
if (!dashboard) return <div className="admin-error-state"><XCircle size={22} /><strong>Workspaceを読み込めませんでした</strong><p>APIが起動しているか確認して、ページを更新してください。</p></div>;
const openCreateSource = () => { setEditingSource(undefined); setSourceDialogOpen(true); };
const openEditSource = (source: SourceRecord) => { setEditingSource(source); setSourceDialogOpen(true); };
const submitSource = async (input: { name: string; subtitle: string; description: string; visibility: Visibility }) => { if (editingSource) await updateSource(editingSource.id, input); else await createSource(input); };
const handleUpload = async (sourceId: string, file: File, appId?: string) => { await uploadRelease(sourceId, file, appId); };
const handleAppSave = async (id: string, patch: Partial<AppRecord>) => { await saveApp(id, { name: patch.name, developerName: patch.developerName, subtitle: patch.subtitle, localizedDescription: patch.localizedDescription, iconURL: patch.iconURL, category: patch.category, appPermissions: patch.appPermissions }); };
return <>
{view === "overview" && <Overview dashboard={dashboard} onCreateSource={openCreateSource} onUpload={() => setUploadOpen(true)} onOpenSource={openEditSource} onOpenRelease={setReleaseItem} />}
{view === "sources" && <SourcesView dashboard={dashboard} onCreateSource={openCreateSource} onEditSource={openEditSource} onOpenJson={setJsonSource} onCopyUrl={(source) => void copySourceUrl(source)} />}
{view === "apps" && <AppsView dashboard={dashboard} onEditApp={setEditingApp} />}
{view === "releases" && <ReleasesView dashboard={dashboard} onUpload={() => setUploadOpen(true)} onOpenRelease={setReleaseItem} onPublish={publishRelease} />}
<SourceDialog key={editingSource?.id || "new-source"} open={sourceDialogOpen} onOpenChange={setSourceDialogOpen} source={editingSource} busy={busy} onSubmit={submitSource} />
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} sources={dashboard.sources} busy={busy} maxUploadBytes={dashboard.limits.maxUploadBytes} onSubmit={handleUpload} />
<AppDialog key={editingApp?.id || "app-editor"} open={Boolean(editingApp)} onOpenChange={(open) => { if (!open) setEditingApp(null); }} app={editingApp} busy={busy} onSubmit={handleAppSave} />
<JsonPreviewDialog source={jsonSource} open={Boolean(jsonSource)} onOpenChange={(open) => { if (!open) setJsonSource(null); }} />
<ReleaseDetailsDialog item={releaseItem} open={Boolean(releaseItem)} onOpenChange={(open) => { if (!open) setReleaseItem(null); }} />
</>;
}