336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
fetchSpaceFiles,
|
|
fetchSpaceFileContent,
|
|
getAppShareLink,
|
|
createAppShareLink,
|
|
revokeAppShareLink,
|
|
} from '../../api';
|
|
import { deriveAppList, type WorkspaceApp } from './app-bridge';
|
|
import { AppRunner } from './AppRunner';
|
|
import { createSpaceGateway } from './app-file-gateway';
|
|
import { buildAppShareDisplayUrl } from './appShareUrl';
|
|
|
|
/**
|
|
* SpaceApps — the workspace "アプリ" tab.
|
|
*
|
|
* Lists the runnable workspace apps (subfolders under `apps/` that contain an
|
|
* `index.html`) and launches a selected one in the AppRunner (sandboxed iframe
|
|
* + postMessage bridge from Stage 1). Each app may carry an optional
|
|
* `apps/{name}/app.json` manifest ({title?, description?, entry?}); when present
|
|
* its title/description are shown, otherwise we fall back to the folder name +
|
|
* `index.html`.
|
|
*
|
|
* NETWORK / SECURITY: the apps themselves run under AppRunner's opaque-origin
|
|
* sandbox with `connect-src 'none'` — this tab only discovers + launches them.
|
|
*/
|
|
export function SpaceApps({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
|
|
const { t } = useTranslation('spaces');
|
|
const [appToRun, setAppToRun] = useState<WorkspaceApp | null>(null);
|
|
|
|
const appsQuery = useQuery({
|
|
queryKey: ['space-apps', spaceId],
|
|
queryFn: () => loadWorkspaceApps(spaceId),
|
|
staleTime: 10_000,
|
|
});
|
|
|
|
const apps = appsQuery.data ?? [];
|
|
|
|
return (
|
|
<div data-testid="space-apps" className="flex flex-col gap-3 p-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-500">
|
|
{t('apps.heading')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
data-testid="space-apps-refresh"
|
|
onClick={() => void appsQuery.refetch()}
|
|
disabled={appsQuery.isFetching}
|
|
title={t('apps.refresh')}
|
|
aria-label={t('apps.refresh')}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:opacity-50"
|
|
>
|
|
<svg className={`h-3.5 w-3.5 ${appsQuery.isFetching ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M2 8a6 6 0 0110.5-4M14 8a6 6 0 01-10.5 4" />
|
|
<path d="M12 2v3h-3M4 14v-3h3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{appsQuery.isError && (
|
|
<p className="text-xs text-red-600">{t('apps.fetchError')}</p>
|
|
)}
|
|
|
|
{!appsQuery.isLoading && !appsQuery.isError && apps.length === 0 && (
|
|
<div
|
|
data-testid="space-apps-empty"
|
|
className="rounded-lg border border-dashed border-hairline bg-surface p-6 text-center text-sm text-slate-500"
|
|
>
|
|
<p className="font-medium text-slate-600">{t('apps.empty.title')}</p>
|
|
<p className="mt-1.5 leading-relaxed">
|
|
{t('apps.empty.body')}
|
|
</p>
|
|
<p className="mt-1.5 text-2xs text-slate-400">
|
|
<Trans
|
|
t={t}
|
|
i18nKey="apps.empty.location"
|
|
components={{ code: <code className="rounded bg-canvas px-1 py-0.5" /> }}
|
|
/>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{apps.length > 0 && (
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{apps.map(app => (
|
|
<div
|
|
key={app.name}
|
|
data-testid={`space-app-${app.name}`}
|
|
className="flex flex-col gap-2 rounded-lg border border-hairline bg-surface p-3"
|
|
>
|
|
<div className="flex min-w-0 items-start gap-2">
|
|
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-[var(--brand-primary)]/10 text-[var(--brand-primary)]" aria-hidden>
|
|
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="2" y="2" width="5" height="5" rx="1" />
|
|
<rect x="9" y="2" width="5" height="5" rx="1" />
|
|
<rect x="2" y="9" width="5" height="5" rx="1" />
|
|
<rect x="9" y="9" width="5" height="5" rx="1" />
|
|
</svg>
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold text-slate-800" title={app.title}>
|
|
{app.title}
|
|
</p>
|
|
{app.description ? (
|
|
<p className="mt-0.5 line-clamp-2 text-2xs text-slate-500">{app.description}</p>
|
|
) : (
|
|
<p className="mt-0.5 truncate font-mono text-2xs text-slate-400">apps/{app.name}/index.html</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
data-testid={`space-app-open-${app.name}`}
|
|
onClick={() => setAppToRun(app)}
|
|
className="mt-auto inline-flex h-8 items-center justify-center gap-1 rounded-md bg-accent px-3 text-xs font-bold text-accent-fg transition-colors hover:opacity-90"
|
|
>
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" stroke="none">
|
|
<path d="M5 3.5v9l7-4.5z" />
|
|
</svg>
|
|
{t('apps.open')}
|
|
</button>
|
|
|
|
{canManage && <AppShareControls spaceId={spaceId} appName={app.name} />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{appToRun && (
|
|
<AppRunner
|
|
gateway={createSpaceGateway(spaceId)}
|
|
appName={appToRun.name}
|
|
entryPath={appToRun.entryPath}
|
|
onClose={() => setAppToRun(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Discover workspace apps for a space: list `apps/`, then for each subfolder
|
|
* check for an index.html and read its optional app.json. Folders without an
|
|
* index.html are skipped (not runnable). A missing `apps/` directory simply
|
|
* yields an empty list (the listing call 404s → caught → []).
|
|
*/
|
|
async function loadWorkspaceApps(spaceId: string): Promise<WorkspaceApp[]> {
|
|
// List the workspace root first (never 404s on a fresh workspace) and only
|
|
// descend into apps/ when that directory actually exists — listing a missing
|
|
// apps/ directly would 404 noisily on every fresh workspace.
|
|
let hasAppsDir = false;
|
|
try {
|
|
const root = await fetchSpaceFiles(spaceId, '');
|
|
hasAppsDir = root.entries.some(e => e.kind === 'directory' && e.name === 'apps');
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (!hasAppsDir) return [];
|
|
|
|
let appDirs: string[];
|
|
try {
|
|
const appsRoot = await fetchSpaceFiles(spaceId, 'apps');
|
|
appDirs = appsRoot.entries.filter(e => e.kind === 'directory').map(e => e.name);
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (appDirs.length === 0) return [];
|
|
|
|
// Fetch each app folder's listing in parallel to find index.html + app.json.
|
|
const perApp = await Promise.all(
|
|
appDirs.map(async name => {
|
|
try {
|
|
const dir = await fetchSpaceFiles(spaceId, `apps/${name}`);
|
|
const names = new Set(dir.entries.filter(e => e.kind !== 'directory').map(e => e.name));
|
|
const hasIndex = names.has('index.html');
|
|
let manifest: string | null = null;
|
|
if (hasIndex && names.has('app.json')) {
|
|
try {
|
|
manifest = await fetchSpaceFileContent(spaceId, `apps/${name}/app.json`);
|
|
} catch {
|
|
manifest = null; // tolerant: a bad/unreadable manifest falls back to defaults
|
|
}
|
|
}
|
|
return { name, hasIndex, manifest };
|
|
} catch {
|
|
return { name, hasIndex: false, manifest: null };
|
|
}
|
|
}),
|
|
);
|
|
|
|
const indexMap = new Map(perApp.map(p => [p.name, p]));
|
|
return deriveAppList(
|
|
appDirs,
|
|
name => indexMap.get(name)?.hasIndex ?? false,
|
|
name => indexMap.get(name)?.manifest ?? null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* AppShareControls — 1 アプリの公開共有リンク管理(canManage のみ表示)。
|
|
*
|
|
* 現在のリンク状態を取得し、未発行なら「リンクを作成」、発行済みなら公開 URL の表示・
|
|
* コピー・失効を提供する。発行/失効は react-query mutation。公開リンクは read-only・
|
|
* ログイン不要・apps/{app}/+output/ に封じ込められる旨を注意書きで明示する。
|
|
*/
|
|
function AppShareControls({ spaceId, appName }: { spaceId: string; appName: string }) {
|
|
const { t } = useTranslation('spaces');
|
|
const qc = useQueryClient();
|
|
const [copied, setCopied] = useState(false);
|
|
const queryKey = ['app-share-link', spaceId, appName];
|
|
|
|
const linkQuery = useQuery({
|
|
queryKey,
|
|
queryFn: () => getAppShareLink(spaceId, appName),
|
|
staleTime: 10_000,
|
|
});
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: () => createAppShareLink(spaceId, appName),
|
|
onSuccess: (data) => {
|
|
qc.setQueryData(queryKey, { token: data.token, shareUrl: data.shareUrl, revokedAt: null });
|
|
},
|
|
});
|
|
|
|
const revokeMut = useMutation({
|
|
mutationFn: () => revokeAppShareLink(spaceId, appName),
|
|
onSuccess: () => {
|
|
qc.setQueryData(queryKey, { token: null, revokedAt: new Date().toISOString() });
|
|
setCopied(false);
|
|
},
|
|
});
|
|
|
|
const link = linkQuery.data;
|
|
const shareUrl = link?.token && link.shareUrl ? link.shareUrl : null;
|
|
const displayUrl = shareUrl ? buildAppShareDisplayUrl(window.location.origin, shareUrl) : null;
|
|
|
|
const copy = async () => {
|
|
if (!displayUrl) return;
|
|
try {
|
|
await navigator.clipboard.writeText(displayUrl);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1800);
|
|
} catch {
|
|
// clipboard 不可(権限なし等)。表示済み URL から手動コピーできるので無視。
|
|
}
|
|
};
|
|
|
|
const onRevoke = () => {
|
|
if (window.confirm(t('appShare.revokeConfirm', { appName }))) {
|
|
revokeMut.mutate();
|
|
}
|
|
};
|
|
|
|
const busy = createMut.isPending || revokeMut.isPending;
|
|
|
|
return (
|
|
<div
|
|
data-testid={`space-app-share-${appName}`}
|
|
className="mt-1 border-t border-hairline pt-2"
|
|
>
|
|
{linkQuery.isLoading ? (
|
|
<p className="text-2xs text-slate-400">{t('appShare.checking')}</p>
|
|
) : displayUrl ? (
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className="inline-flex items-center gap-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-2xs font-semibold text-emerald-600 dark:text-emerald-400"
|
|
data-testid={`space-app-share-badge-${appName}`}
|
|
>
|
|
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
|
|
</svg>
|
|
{t('appShare.issued')}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
readOnly
|
|
value={displayUrl}
|
|
data-testid={`space-app-share-url-${appName}`}
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
className="w-full truncate rounded border border-hairline bg-canvas px-2 py-1 font-mono text-2xs text-slate-600"
|
|
/>
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
data-testid={`space-app-share-copy-${appName}`}
|
|
onClick={() => void copy()}
|
|
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface"
|
|
>
|
|
{copied ? t('appShare.copied') : t('appShare.copyUrl')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid={`space-app-share-revoke-${appName}`}
|
|
onClick={onRevoke}
|
|
disabled={busy}
|
|
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50 dark:hover:bg-red-950/30"
|
|
>
|
|
{t('appShare.revoke')}
|
|
</button>
|
|
</div>
|
|
<p className="text-2xs leading-relaxed text-amber-600">
|
|
<Trans
|
|
t={t}
|
|
i18nKey="appShare.warning"
|
|
components={{ code: <code className="rounded bg-canvas px-1" />, strong: <strong /> }}
|
|
/>
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
<button
|
|
type="button"
|
|
data-testid={`space-app-share-create-${appName}`}
|
|
onClick={() => createMut.mutate()}
|
|
disabled={busy}
|
|
className="inline-flex h-7 items-center gap-1 self-start rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface disabled:opacity-50"
|
|
>
|
|
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
|
|
</svg>
|
|
{createMut.isPending ? t('appShare.creating') : t('appShare.create')}
|
|
</button>
|
|
{(createMut.isError || revokeMut.isError) && (
|
|
<p className="text-2xs text-red-600">{t('appShare.opError')}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|