This commit is contained in:
@@ -1,8 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchSpaceFiles, fetchSpaceFileContent } from '../../api';
|
||||
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.
|
||||
@@ -17,7 +26,8 @@ import { AppRunner } from './AppRunner';
|
||||
* 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 }: { spaceId: string }) {
|
||||
export function SpaceApps({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [appToRun, setAppToRun] = useState<WorkspaceApp | null>(null);
|
||||
|
||||
const appsQuery = useQuery({
|
||||
@@ -32,15 +42,15 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
<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="再読み込み"
|
||||
aria-label="再読み込み"
|
||||
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">
|
||||
@@ -51,7 +61,7 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
</div>
|
||||
|
||||
{appsQuery.isError && (
|
||||
<p className="text-xs text-red-600">アプリ一覧の取得に失敗しました。</p>
|
||||
<p className="text-xs text-red-600">{t('apps.fetchError')}</p>
|
||||
)}
|
||||
|
||||
{!appsQuery.isLoading && !appsQuery.isError && apps.length === 0 && (
|
||||
@@ -59,12 +69,16 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
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">まだアプリがありません</p>
|
||||
<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">
|
||||
アプリはワークスペースの <code className="rounded bg-canvas px-1 py-0.5">apps/</code> フォルダ(<code className="rounded bg-canvas px-1 py-0.5">apps/{名前}/index.html</code>)に置かれます。
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="apps.empty.location"
|
||||
components={{ code: <code className="rounded bg-canvas px-1 py-0.5" /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -106,8 +120,10 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
<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>
|
||||
@@ -115,7 +131,7 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
|
||||
{appToRun && (
|
||||
<AppRunner
|
||||
spaceId={spaceId}
|
||||
gateway={createSpaceGateway(spaceId)}
|
||||
appName={appToRun.name}
|
||||
entryPath={appToRun.entryPath}
|
||||
onClose={() => setAppToRun(null)}
|
||||
@@ -182,3 +198,138 @@ async function loadWorkspaceApps(spaceId: string): Promise<WorkspaceApp[]> {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user