初回コミット: 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対応
This commit is contained in:
amania-jailbreak
2026-08-05 10:09:58 +09:00
commit 93825ebc64
59 changed files with 16065 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
"use client";
import * as Toast from "@radix-ui/react-toast";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { useEffect, useMemo, useState, type ComponentType, type PropsWithChildren } from "react";
import { AppWindow, BookOpen, Boxes, ChevronDown, ExternalLink, FileArchive, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings2, UploadCloud, X } from "lucide-react";
import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard";
import { useDashboard } from "./dashboard-provider";
type NavItem = { href: string; label: string; icon: ComponentType<{ size?: number; strokeWidth?: number }> };
const primaryNav: NavItem[] = [
{ href: "/dashboard", label: "概要", icon: Gauge },
{ href: "/dashboard/sources", label: "Sources", icon: Boxes },
{ href: "/dashboard/apps", label: "アプリ", icon: AppWindow },
{ href: "/dashboard/releases", label: "リリース", icon: FileArchive },
];
const secondaryNav: NavItem[] = [
{ href: "https://faq.altstore.io/developers/distribute-with-altstore-pal", label: "PALドキュメント", icon: BookOpen },
{ href: "/dashboard/settings", label: "設定", icon: Settings2 },
];
function isActive(pathname: string, href: string) {
if (href === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(href);
}
function SideNavigation({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
const pathname = usePathname();
const router = useRouter();
const { dashboard } = useDashboard();
const renderItem = (item: NavItem) => {
const external = item.href.startsWith("http");
const Icon = item.icon;
const className = `admin-nav-item${isActive(pathname, item.href) ? " active" : ""}`;
const content = <><Icon size={16} strokeWidth={1.8} /><span>{item.label}</span>{external && <ExternalLink className="admin-nav-external" size={12} />}</>;
if (external) return <a key={item.href} className={className} href={item.href} target="_blank" rel="noreferrer" onClick={onNavigate}>{content}</a>;
return <Link key={item.href} className={className} href={item.href} onClick={onNavigate}>{content}</Link>;
};
const storagePercent = dashboard ? Math.min(100, (dashboard.usage.storageBytes / Math.max(1, dashboard.limits.maxStorageBytes)) * 100) : 0;
return <div className="admin-sidebar-inner">
<button className="admin-brand" onClick={() => router.push("/dashboard")} aria-label="AltDock 概要へ移動"><span className="admin-brand-mark">A</span><span className={collapsed ? "sr-only" : "admin-brand-name"}>AltDock</span><span className={collapsed ? "sr-only" : "admin-brand-beta"}>BETA</span></button>
{!collapsed && <div className="admin-workspace-switcher"><span className="admin-workspace-avatar">{dashboard?.workspace.name.slice(0, 1).toUpperCase() || "D"}</span><span className="admin-workspace-copy"><small>WORKSPACE</small><strong>{dashboard?.workspace.name || "Developer Workspace"}</strong></span><ChevronDown size={14} /></div>}
<div className={collapsed ? "sr-only" : "admin-nav-label"}></div>
<nav className="admin-nav" aria-label="管理画面ナビゲーション">{primaryNav.map(renderItem)}</nav>
<div className={collapsed ? "sr-only" : "admin-nav-label admin-nav-label-secondary"}></div>
<nav className="admin-nav admin-nav-secondary" aria-label="その他のナビゲーション">{secondaryNav.map(renderItem)}</nav>
<div className="admin-sidebar-spacer" />
{!collapsed && <div className="admin-storage-card"><div className="admin-storage-row"><span></span><strong>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</strong></div><div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div><small>Free workspace plan</small></div>}
{!collapsed && <div className="admin-user-card"><span className="admin-user-avatar">D</span><span><strong>Demo Developer</strong><small>demo@altdock.local</small></span><span className="admin-user-menu">···</span></div>}
</div>;
}
function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const router = useRouter();
const commands = useMemo(() => primaryNav.map((item) => ({ ...item, action: () => router.push(item.href) })), [router]);
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="command-dialog"><DialogTitle className="sr-only"></DialogTitle><DialogDescription className="sr-only"></DialogDescription><Command label="画面を検索"><CommandInput placeholder="画面や操作を検索…" autoFocus /><CommandList><CommandEmpty></CommandEmpty><CommandGroup heading="移動"><CommandItem onSelect={() => { commands[0].action(); onOpenChange(false); }}><Gauge size={15} /><span className="command-shortcut">G O</span></CommandItem><CommandItem onSelect={() => { commands[1].action(); onOpenChange(false); }}><Boxes size={15} />Sources<span className="command-shortcut">G S</span></CommandItem><CommandItem onSelect={() => { commands[2].action(); onOpenChange(false); }}><AppWindow size={15} /><span className="command-shortcut">G A</span></CommandItem><CommandItem onSelect={() => { commands[3].action(); onOpenChange(false); }}><FileArchive size={15} /><span className="command-shortcut">G R</span></CommandItem></CommandGroup><CommandGroup heading="操作"><CommandItem onSelect={() => { router.push("/dashboard/releases?upload=1"); onOpenChange(false); }}><UploadCloud size={15} />ADPをアップロード</CommandItem></CommandGroup></CommandList></Command></DialogContent></Dialog>;
}
export function AdminShell({ children }: PropsWithChildren) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [commandOpen, setCommandOpen] = useState(false);
const pathname = usePathname();
const { notice, dismissNotice, dashboard } = useDashboard();
useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setCommandOpen(true); }
};
window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, []);
const pageLabel = pathname === "/dashboard" ? "概要" : pathname.includes("/sources") ? "Sources" : pathname.includes("/apps") ? "アプリ" : pathname.includes("/releases") ? "リリース" : "設定";
return <Toast.Provider swipeDirection="right">
<div className="admin-shell">
<aside className={`admin-sidebar ${collapsed ? "is-collapsed" : ""}`}><SideNavigation collapsed={collapsed} /></aside>
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}><DialogContent className="mobile-nav-dialog"><DialogTitle className="sr-only"></DialogTitle><DialogDescription className="sr-only"></DialogDescription><button className="mobile-nav-close" onClick={() => setMobileOpen(false)} aria-label="ナビゲーションを閉じる"><X size={17} /></button><SideNavigation onNavigate={() => setMobileOpen(false)} /></DialogContent></Dialog>
<section className="admin-main-column">
<header className="admin-topbar"><div className="admin-topbar-left"><Button className="mobile-menu-button" variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="ナビゲーションを開く"><Menu size={18} /></Button><div className="admin-breadcrumb"><span>Workspace</span><span>/</span><strong>{pageLabel}</strong></div></div><div className="admin-topbar-actions"><button className="admin-search-trigger" onClick={() => setCommandOpen(true)}><Search size={15} /><span></span><kbd> K</kbd></button><Link className="admin-topbar-action" href="/dashboard/releases?upload=1"><Plus size={15} /></Link>{AUTH_MODE === "oidc" ? <a className="admin-auth-link" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : <a className="admin-auth-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">API status</a>}</div></header>
<div className="admin-content">{children}</div>
</section>
<button className="admin-collapse-toggle" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? "サイドバーを展開" : "サイドバーを折りたたむ"}>{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}</button>
</div>
{notice && <Toast.Root open onOpenChange={(open) => { if (!open) dismissNotice(); }} className={`admin-toast toast-${notice.tone}`}><Toast.Title>{notice.tone === "error" ? "処理に失敗しました" : notice.tone === "info" ? "処理中" : "完了"}</Toast.Title><Toast.Description>{notice.text}</Toast.Description><Toast.Close aria-label="通知を閉じる">×</Toast.Close></Toast.Root>}
<Toast.Viewport className="admin-toast-viewport" />
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
</Toast.Provider>;
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type PropsWithChildren } from "react";
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
import { API_BASE, api, type DashboardData, type Notice, type Visibility } from "@/app/lib/dashboard";
type SourceInput = Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility"> & Partial<Pick<SourceRecord, "iconURL" | "headerURL" | "website" | "tintColor">>;
type UploadPlan =
| { mode: "single"; uploadUrl: string }
| { mode: "multipart"; uploadId: string; partSizeBytes: number; parts: Array<{ partNumber: number; uploadUrl: string }> };
type AppPatch = Partial<Pick<AppRecord, "name" | "developerName" | "subtitle" | "localizedDescription" | "iconURL" | "category" | "appPermissions">>;
type DashboardContextValue = {
dashboard: DashboardData | null;
loading: boolean;
busy: boolean;
notice: Notice | null;
refresh: () => Promise<void>;
dismissNotice: () => void;
createSource: (input: SourceInput) => Promise<void>;
updateSource: (id: string, input: Partial<SourceInput>) => Promise<void>;
changeVisibility: (id: string, visibility: Visibility) => Promise<void>;
uploadRelease: (sourceId: string, file: File, appId?: string) => Promise<ReleaseRecord | null>;
saveApp: (id: string, patch: AppPatch) => Promise<void>;
publishRelease: (id: string) => Promise<void>;
copySourceUrl: (source: SourceRecord) => Promise<void>;
};
const DashboardContext = createContext<DashboardContextValue | null>(null);
function messageFor(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export function DashboardProvider({ children }: PropsWithChildren) {
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
setDashboard(await api<DashboardData>("/api/v1/dashboard"));
} catch (error) {
setNotice({ tone: "error", text: `APIに接続できません。${messageFor(error, "APIを起動してください。")}` });
} finally {
setLoading(false);
}
}, []);
// The provider owns the external dashboard request and refreshes it on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { void refresh(); }, [refresh]);
const withBusy = useCallback(async (work: () => Promise<void>, success: string, failure: string) => {
setBusy(true);
try {
await work();
setNotice({ tone: "success", text: success });
await refresh();
} catch (error) {
setNotice({ tone: "error", text: messageFor(error, failure) });
throw error;
} finally {
setBusy(false);
}
}, [refresh]);
const createSource = useCallback(async (input: SourceInput) => {
await withBusy(
async () => { await api("/api/v1/sources", { method: "POST", body: JSON.stringify(input) }); },
"Sourceを作成しました。",
"Sourceを作成できませんでした。",
);
}, [withBusy]);
const updateSource = useCallback(async (id: string, input: Partial<SourceInput>) => {
await withBusy(
async () => { await api(`/api/v1/sources/${id}`, { method: "PATCH", body: JSON.stringify(input) }); },
"Sourceを更新しました。",
"Sourceを更新できませんでした。",
);
}, [withBusy]);
const changeVisibility = useCallback(async (id: string, visibility: Visibility) => {
await withBusy(
async () => { await api(`/api/v1/sources/${id}`, { method: "PATCH", body: JSON.stringify({ visibility }) }); },
"Sourceの公開状態を更新しました。",
"公開状態を変更できませんでした。",
);
}, [withBusy]);
const uploadRelease = useCallback(async (sourceId: string, file: File, appId?: string) => {
setBusy(true);
setNotice({ tone: "info", text: "ADPをアップロードし、Manifestとアセットを検証しています。" });
try {
const init = await api<{ upload: { id: string }; uploadPlan: UploadPlan }>("/api/v1/uploads", { method: "POST", body: JSON.stringify({ sourceId, appId, filename: file.name, sizeBytes: file.size }) });
const uploadedParts: Array<{ partNumber: number; etag: string }> = [];
if (init.uploadPlan.mode === "multipart") {
for (const part of init.uploadPlan.parts) {
const start = (part.partNumber - 1) * init.uploadPlan.partSizeBytes;
const response = await fetch(part.uploadUrl, { method: "PUT", body: file.slice(start, Math.min(file.size, start + init.uploadPlan.partSizeBytes)) });
if (!response.ok) throw new Error(`ADPパート${part.partNumber}の転送に失敗しました。`);
const etag = response.headers.get("etag");
if (!etag) throw new Error(`ADPパート${part.partNumber}のETagを取得できませんでした。`);
uploadedParts.push({ partNumber: part.partNumber, etag });
}
} else {
const response = await fetch(init.uploadPlan.uploadUrl, { method: "PUT", headers: { "content-type": "application/zip" }, body: file });
if (!response.ok) throw new Error("ADPファイルの転送に失敗しました。");
}
let complete = await api<{ release?: ReleaseRecord }>(`/api/v1/uploads/${init.upload.id}/complete`, { method: "POST", body: JSON.stringify({ parts: uploadedParts.length ? uploadedParts : undefined }) });
if (!complete.release) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1200));
const status = await api<{ release?: ReleaseRecord }>(`/api/v1/uploads/${init.upload.id}`);
if (status.release) { complete = status; break; }
}
}
setNotice({ tone: complete.release?.status === "ready" ? "success" : "info", text: complete.release ? `v${complete.release.version}build ${complete.release.buildVersion})の検証が完了しました。` : "アップロードをキューに追加しました。処理完了後に更新してください。" });
await refresh();
return complete.release || null;
} catch (error) {
setNotice({ tone: "error", text: messageFor(error, "ADPを処理できませんでした。") });
throw error;
} finally {
setBusy(false);
}
}, [refresh]);
const saveApp = useCallback(async (id: string, patch: AppPatch) => {
await withBusy(
async () => { await api(`/api/v1/apps/${id}`, { method: "PATCH", body: JSON.stringify(patch) }); },
"アプリのSourceメタデータを更新しました。",
"アプリ情報を更新できませんでした。",
);
}, [withBusy]);
const publishRelease = useCallback(async (id: string) => {
await withBusy(
async () => { await api(`/api/v1/releases/${id}/publish`, { method: "POST", body: "{}" }); },
"リリースを公開しました。",
"リリースを公開できませんでした。",
);
}, [withBusy]);
const copySourceUrl = useCallback(async (source: SourceRecord) => {
const url = `${API_BASE.replace(/\/$/, "")}/sources/${source.slug}/source.json`;
await navigator.clipboard?.writeText(url);
setNotice({ tone: "success", text: "Source URLをコピーしました。AltStore PALのSource追加画面に貼り付けられます。" });
}, []);
const value = useMemo<DashboardContextValue>(() => ({ dashboard, loading, busy, notice, refresh, dismissNotice: () => setNotice(null), createSource, updateSource, changeVisibility, uploadRelease, saveApp, publishRelease, copySourceUrl }), [dashboard, loading, busy, notice, refresh, createSource, updateSource, changeVisibility, uploadRelease, saveApp, publishRelease, copySourceUrl]);
return <DashboardContext.Provider value={value}>{children}</DashboardContext.Provider>;
}
export function useDashboard() {
const context = useContext(DashboardContext);
if (!context) throw new Error("useDashboard must be used inside DashboardProvider");
return context;
}
+159
View File
@@ -0,0 +1,159 @@
"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">11</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); }} />
</>;
}
+7
View File
@@ -0,0 +1,7 @@
import type { HTMLAttributes } from "react";
import { cn } from "./utils";
export function Badge({ className, tone = "neutral", ...props }: HTMLAttributes<HTMLSpanElement> & { tone?: "neutral" | "info" | "warning" | "danger" | "success" }) {
return <span className={cn("ui-badge", `ui-badge-${tone}`, className)} {...props} />;
}
+9
View File
@@ -0,0 +1,9 @@
import type { ButtonHTMLAttributes } from "react";
import { cn } from "./utils";
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
export function Button({ className, variant = "primary", size = "default", ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; size?: "sm" | "default" | "icon" }) {
return <button className={cn("ui-button", `ui-button-${variant}`, `ui-button-${size}`, className)} {...props} />;
}
+28
View File
@@ -0,0 +1,28 @@
import { Command as CommandPrimitive } from "cmdk";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "./utils";
export function Command({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive>) {
return <CommandPrimitive className={cn("ui-command", className)} {...props} />;
}
export function CommandInput({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Input>) {
return <CommandPrimitive.Input className={cn("ui-command-input", className)} {...props} />;
}
export function CommandList({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.List>) {
return <CommandPrimitive.List className={cn("ui-command-list", className)} {...props} />;
}
export function CommandEmpty({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty className={cn("ui-command-empty", className)} {...props} />;
}
export function CommandGroup({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Group>) {
return <CommandPrimitive.Group className={cn("ui-command-group", className)} {...props} />;
}
export function CommandItem({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Item>) {
return <CommandPrimitive.Item className={cn("ui-command-item", className)} {...props} />;
}
+33
View File
@@ -0,0 +1,33 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import type { ComponentPropsWithoutRef, PropsWithChildren } from "react";
import { cn } from "./utils";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export function DialogContent({ className, children, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
return <DialogPrimitive.Portal><DialogPrimitive.Overlay className="ui-dialog-overlay" /><DialogPrimitive.Content className={cn("ui-dialog-content", className)} {...props}>{children}<DialogPrimitive.Close className="ui-dialog-close" aria-label="閉じる"><X size={16} /></DialogPrimitive.Close></DialogPrimitive.Content></DialogPrimitive.Portal>;
}
export function DialogHeader({ className, ...props }: ComponentPropsWithoutRef<"div">) {
return <div className={cn("ui-dialog-header", className)} {...props} />;
}
export function DialogTitle({ className, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title className={cn("ui-dialog-title", className)} {...props} />;
}
export function DialogDescription({ className, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
return <DialogPrimitive.Description className={cn("ui-dialog-description", className)} {...props} />;
}
export function DialogFooter({ className, ...props }: ComponentPropsWithoutRef<"div">) {
return <div className={cn("ui-dialog-footer", className)} {...props} />;
}
export function DialogForm({ children, ...props }: PropsWithChildren<ComponentPropsWithoutRef<"form">>) {
return <form className="ui-dialog-form" {...props}>{children}</form>;
}
+19
View File
@@ -0,0 +1,19 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "./utils";
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export function DropdownMenuContent({ className, sideOffset = 6, ...props }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>) {
return <DropdownMenuPrimitive.Portal><DropdownMenuPrimitive.Content sideOffset={sideOffset} className={cn("ui-dropdown-content", className)} {...props} /></DropdownMenuPrimitive.Portal>;
}
export function DropdownMenuItem({ className, ...props }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>) {
return <DropdownMenuPrimitive.Item className={cn("ui-dropdown-item", className)} {...props} />;
}
export function DropdownMenuSeparator({ className, ...props }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>) {
return <DropdownMenuPrimitive.Separator className={cn("ui-dropdown-separator", className)} {...props} />;
}
+18
View File
@@ -0,0 +1,18 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "./utils";
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
return <TabsPrimitive.List className={cn("ui-tabs-list", className)} {...props} />;
}
export function TabsTrigger({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
return <TabsPrimitive.Trigger className={cn("ui-tabs-trigger", className)} {...props} />;
}
export function TabsContent({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn("ui-tabs-content", className)} {...props} />;
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}