sync: update from private repo (f6d625db)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-26 03:35:45 +00:00
parent 29ccaf1e92
commit b857c33ef6
371 changed files with 31312 additions and 8172 deletions
+163 -12
View File
@@ -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/&#123;&#125;/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>
);
}