mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(share): apply third-round design review to owner share UI
- Share toast rebuilt as ShareFeedbackToast to the design: solid red/green badge, plain underlined 重试, close; used for update success/failure and the deleted-project stop failure. - The ··· deploy menu drops down under the trigger, right-aligned to the header tools, over the share panel. - Signed-out share button reads 「登录后分享」; update notice loses its box; 更新链接 / 登录后更新 / K5 retry use the design's blue and retry icon. - Signed-out observed share reuses the published link row and copy button (chain icon, green check 已复制) and no longer overflows its card. - Capture specs follow the intended copy and placement changes.
This commit is contained in:
@@ -13,6 +13,7 @@ import { useAfterExportShareGuide } from './share/useAfterExportShareGuide';
|
||||
import { useShareGuideAppUserId } from './share/useShareGuideAppUserId';
|
||||
import { useProjectShareHistory } from './share/useProjectShareHistory';
|
||||
import { notifyProjectShareHistoryChanged } from './share/share-publication-events';
|
||||
import { ShareFeedbackToast } from './share/ShareFeedbackToast';
|
||||
import { SharePanelHeader } from './share/SharePanelHeader';
|
||||
import { ShareMoreMenu } from './share/ShareMoreMenu';
|
||||
import shareEntryStyles from './share/ShareEntry.module.css';
|
||||
@@ -18258,13 +18259,9 @@ function HtmlViewer({
|
||||
/>
|
||||
) : null}
|
||||
{workspaceActive && updateToast ? (
|
||||
<Toast
|
||||
className="od-toast-share-feedback"
|
||||
<ShareFeedbackToast
|
||||
message={t(updateToast === 'success' ? 'fileViewer.shareUpdateSuccess' : updateToast === 'failure' ? 'fileViewer.shareUpdateFailed' : 'fileViewer.shareUpdateUncertain')}
|
||||
tone={updateToast === 'success' ? 'success' : 'error'}
|
||||
role={updateToast === 'success' ? 'status' : 'alert'}
|
||||
placement="top"
|
||||
portalToBody
|
||||
ttlMs={updateToast === 'success' ? 2200 : 0}
|
||||
actionLabel={updateToast === 'failure' ? t('fileViewer.shareUpdateRetry') : undefined}
|
||||
onAction={updateToast === 'failure' ? () => { void updateCurrentFilePublic(); } : undefined}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useT } from '../../i18n';
|
||||
import { Toast } from '../Toast';
|
||||
import { ShareFeedbackToast } from '../share/ShareFeedbackToast';
|
||||
import type { useDeletedShareNotices } from './useDeletedShareNotices';
|
||||
|
||||
/** Show one global Toast at a time; a dismissal must not hide other failed files. */
|
||||
@@ -10,16 +10,11 @@ export function DeletedShareNotices({ state }: { state: ReturnType<typeof useDel
|
||||
if (!current) return null;
|
||||
const { notice, row } = current;
|
||||
const loading = Boolean(row.retrying || row.busy);
|
||||
return <Toast
|
||||
return <ShareFeedbackToast
|
||||
key={JSON.stringify([notice.id, row.filePath, row.slug])}
|
||||
className="od-toast-share-feedback"
|
||||
message={t(row.retrying ? 'designs.deletedShareRetrying' : 'designs.deletedShareFailed', { filePath: row.filePath })}
|
||||
details={row.failed ? t('ds.actionFailed') : undefined}
|
||||
tone={loading ? 'loading' : 'error'}
|
||||
role={loading ? 'status' : 'alert'}
|
||||
placement="top"
|
||||
portalToBody
|
||||
ttlMs={0}
|
||||
actionLabel={loading ? undefined : t('designs.deletedShareRetry')}
|
||||
onAction={loading ? undefined : () => { void state.retry(notice, row); }}
|
||||
onDismiss={() => state.dismissRow(notice.id, row)}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
line-height: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Inside ShareTab this is the design's unboxed `.share-status-panel .notice`:
|
||||
flush with the panel's content edge, no border/fill/padding. */
|
||||
.backfillOnly {
|
||||
margin: 4px 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -42,21 +44,35 @@
|
||||
@keyframes commentSyncSpin { to { transform: rotate(360deg); } }
|
||||
.login { margin-block-start: 4px; }
|
||||
.login button { text-decoration: underline; }
|
||||
.retryRow { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-start: 4px; }
|
||||
.retryRow p { flex: 1; }
|
||||
.retryRow { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 36px; margin-block-start: 4px; }
|
||||
.retryRow p { flex: 1; min-width: 0; }
|
||||
.backfillOnly .retryRow p { color: #5c5c5c; }
|
||||
/* Design `.share-status-panel .notice .secondary`: fixed 88px, icon + label. */
|
||||
.retry {
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
flex: 0 0 88px;
|
||||
width: 88px;
|
||||
min-width: 88px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #eaf1ff;
|
||||
color: #2670e8;
|
||||
background: #edf3ff;
|
||||
color: #245fce;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.retry > svg { flex: 0 0 13px; width: 13px; height: 13px; }
|
||||
.retry:hover:not(:disabled) { background: #e0eaff; color: #194eaf; }
|
||||
.retry:active:not(:disabled) { background: #d3e1ff; color: #194eaf; }
|
||||
.retry:focus-visible { outline: 2px solid #245fce; outline-offset: 2px; }
|
||||
.retry:disabled { opacity: 0.6; cursor: default; }
|
||||
.retryError { background: #fff0f0; color: #c9363e; }
|
||||
.retryError:hover:not(:disabled) { background: #ffe2e3; color: #b52a32; }
|
||||
|
||||
@@ -6,6 +6,15 @@ import { AmrLoginPill } from '../AmrLoginPill';
|
||||
import { useCommentSyncState } from './useCommentSyncState';
|
||||
import styles from './CommentSyncBanner.module.css';
|
||||
|
||||
/** The design's `.notice .secondary` retry glyph (K5 and the backfill/align retry rows). */
|
||||
function RetryIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M20 11a8 8 0 0 0-14.3-4.9L4 8M4 4v4h4M4 13a8 8 0 0 0 14.3 4.9L20 16m0 4v-4h-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-side comment sync banner. Picks AT MOST ONE state to show, in this
|
||||
* priority order:
|
||||
@@ -140,6 +149,7 @@ export function CommentSyncBanner({ projectId, workspaceContext, filePath, inclu
|
||||
onClick={() => void retryBackfill()}
|
||||
disabled={retrying}
|
||||
>
|
||||
<RetryIcon />
|
||||
{t('preview.retry')}
|
||||
</button>
|
||||
) : null}
|
||||
@@ -156,6 +166,7 @@ export function CommentSyncBanner({ projectId, workspaceContext, filePath, inclu
|
||||
<div className={styles.banner} role="status">
|
||||
<p>{t('fileViewer.commentSync.alignFailedBody')}</p>
|
||||
<button type="button" className={styles.retry} onClick={() => void retryAlign()} disabled={retrying}>
|
||||
<RetryIcon />
|
||||
{t('preview.retry')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/* Owner S6-OK / S6-ERR / S14-ERR global share toast. Values follow the final
|
||||
design board (.board .toastwrap .toast): #202020 pill, 10px/16px padding,
|
||||
12px/18px text, solid 16px status badge, underlined white text action. */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
left: 50%;
|
||||
z-index: 1800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(560px, calc(100vw - 32px));
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
background: #202020;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
box-shadow: 0 6px 24px #1c1b1a12, 0 2px 6px #1c1b1a0a;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: auto;
|
||||
animation: enter 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.leaving {
|
||||
animation: exit 140ms cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||
}
|
||||
@keyframes enter {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-6px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes exit {
|
||||
from { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
to { opacity: 0; transform: translateX(-50%) translateY(-6px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast,
|
||||
.leaving {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
flex: 0 0 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.badge svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
.success {
|
||||
background: #4C9461;
|
||||
}
|
||||
.error {
|
||||
background: #E5484D;
|
||||
}
|
||||
.loading {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.message {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.details {
|
||||
color: #bdbdbd;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.action {
|
||||
flex: none;
|
||||
/* Override the global `button` 36px height / min-height from primitives.css. */
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action:hover {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.close {
|
||||
display: flex;
|
||||
flex: 0 0 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
min-width: 0;
|
||||
height: 20px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #bdbdbd;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.action:focus-visible,
|
||||
.close:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Global share-result toast (Owner S6-OK / S6-ERR / S14-ERR).
|
||||
//
|
||||
// Deliberately separate from the generic `Toast`: the share design is a dark
|
||||
// pill with a SOLID status badge (green check / red "!"), the message, an
|
||||
// optional plain underlined text action (never a capsule button), and a close
|
||||
// cross. Restyling the shared Toast for this would leak into every other
|
||||
// feature that uses it.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useT } from '../../i18n';
|
||||
import { Icon } from '../Icon';
|
||||
import styles from './ShareFeedbackToast.module.css';
|
||||
|
||||
export type ShareFeedbackTone = 'success' | 'error' | 'loading';
|
||||
|
||||
export interface ShareFeedbackToastProps {
|
||||
tone: ShareFeedbackTone;
|
||||
message: string;
|
||||
/** Secondary line, e.g. why a manual retry did not confirm. */
|
||||
details?: string | null;
|
||||
actionLabel?: string | null;
|
||||
onAction?: () => void;
|
||||
onDismiss: () => void;
|
||||
/** Auto-dismiss after this many ms; 0 keeps the toast until dismissed. */
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
// Matches the `.leaving` exit animation in the CSS Module.
|
||||
const EXIT_MS = 140;
|
||||
|
||||
export function ShareFeedbackToast({
|
||||
tone,
|
||||
message,
|
||||
details,
|
||||
actionLabel,
|
||||
onAction,
|
||||
onDismiss,
|
||||
ttlMs = 0,
|
||||
}: ShareFeedbackToastProps) {
|
||||
const t = useT();
|
||||
// Portal only after mount so SSR and the first hydration render agree.
|
||||
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => setPortalTarget(document.body), []);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
||||
// Callers pass a fresh `onDismiss` closure every render; keep the timer keyed
|
||||
// only on what should re-arm it so parent churn cannot postpone the deadline.
|
||||
const onDismissRef = useRef(onDismiss);
|
||||
useEffect(() => {
|
||||
onDismissRef.current = onDismiss;
|
||||
}, [onDismiss]);
|
||||
|
||||
useEffect(() => {
|
||||
setLeaving(false);
|
||||
if (!Number.isFinite(ttlMs) || ttlMs <= 0) return;
|
||||
const fadeId = window.setTimeout(() => setLeaving(true), Math.max(0, ttlMs - EXIT_MS));
|
||||
const dismissId = window.setTimeout(() => onDismissRef.current(), ttlMs);
|
||||
return () => {
|
||||
window.clearTimeout(fadeId);
|
||||
window.clearTimeout(dismissId);
|
||||
};
|
||||
}, [message, details, ttlMs]);
|
||||
|
||||
const role = tone === 'error' ? 'alert' : 'status';
|
||||
const toast = (
|
||||
<div
|
||||
className={`${styles.toast}${leaving ? ` ${styles.leaving}` : ''}`}
|
||||
role={role}
|
||||
aria-live={role === 'alert' ? 'assertive' : 'polite'}
|
||||
data-tone={tone}
|
||||
>
|
||||
<span className={`${styles.badge} ${styles[tone]}`} aria-hidden>
|
||||
{tone === 'success' ? (
|
||||
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m3 8 3 3 7-7" />
|
||||
</svg>
|
||||
) : tone === 'error' ? '!' : <Icon name="spinner" size={11} />}
|
||||
</span>
|
||||
<span className={styles.text}>
|
||||
<span className={styles.message}>{message}</span>
|
||||
{details ? <span className={styles.details}>{details}</span> : null}
|
||||
</span>
|
||||
{actionLabel && onAction ? (
|
||||
<button type="button" className={styles.action} onClick={onAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className={styles.close} onClick={onDismiss} aria-label={t('common.dismiss')}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m6 6 12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return portalTarget ? createPortal(toast, portalTarget) : null;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ interface ShareMoreAction {
|
||||
|
||||
const MENU_WIDTH = 214;
|
||||
const VIEWPORT_GUTTER = 8;
|
||||
const MENU_GAP = 4;
|
||||
const MENU_GAP = 6;
|
||||
|
||||
/** Disclosure only: deployment, permissions and pending work remain owned by the host. */
|
||||
export function ShareMoreMenu({ label, items }: { label: string; items: readonly ShareMoreAction[] }) {
|
||||
@@ -40,20 +40,16 @@ export function ShareMoreMenu({ label, items }: { label: string; items: readonly
|
||||
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
||||
const maxWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2);
|
||||
const width = Math.min(MENU_WIDTH, maxWidth);
|
||||
const panel = root.current?.closest<HTMLElement>('.share-menu-popover')?.getBoundingClientRect() ?? anchor;
|
||||
const rightSpace = viewportWidth - panel.right - VIEWPORT_GUTTER;
|
||||
const leftSpace = panel.left - VIEWPORT_GUTTER;
|
||||
// Place beside the whole share panel, not beside its header button: a
|
||||
// button-relative left placement can put the portal underneath the panel.
|
||||
const left = rightSpace >= width + MENU_GAP
|
||||
? panel.right + MENU_GAP
|
||||
: leftSpace >= width + MENU_GAP
|
||||
? panel.left - width - MENU_GAP
|
||||
: rightSpace >= leftSpace
|
||||
? Math.min(viewportWidth - width - VIEWPORT_GUTTER, panel.right + MENU_GAP)
|
||||
: Math.max(VIEWPORT_GUTTER, panel.left - width - MENU_GAP);
|
||||
// Design S12: a dropdown directly under the header "···", right-aligned
|
||||
// to the header tool group (the close button's outer edge) and layered
|
||||
// over the share panel's own content — not a flyout beside the panel.
|
||||
const toolsRight = root.current?.parentElement?.getBoundingClientRect().right ?? anchor.right;
|
||||
const left = Math.max(VIEWPORT_GUTTER, Math.min(toolsRight - width, viewportWidth - width - VIEWPORT_GUTTER));
|
||||
const menuHeight = menu.current?.getBoundingClientRect().height ?? 0;
|
||||
const top = Math.max(VIEWPORT_GUTTER, Math.min(anchor.top, window.innerHeight - menuHeight - VIEWPORT_GUTTER));
|
||||
const below = anchor.bottom + MENU_GAP;
|
||||
const top = below + menuHeight + VIEWPORT_GUTTER <= window.innerHeight
|
||||
? below
|
||||
: Math.max(VIEWPORT_GUTTER, anchor.top - MENU_GAP - menuHeight);
|
||||
setPosition({ position: 'fixed', left, top, width, maxWidth: `calc(100vw - ${VIEWPORT_GUTTER * 2}px)` });
|
||||
};
|
||||
placeMenu();
|
||||
|
||||
@@ -49,21 +49,50 @@
|
||||
.signInPrimaryAction:focus-visible { outline: 2px solid #1677FF; outline-offset: 2px; }
|
||||
.signInPrimaryAction:global(.is-signing) { cursor: default; background: #5A5A5C; }
|
||||
|
||||
/* S13: matches the design's `.secondary` button inside the update notice —
|
||||
auto width, light-gray fill, not the dark full-width primary. */
|
||||
/* S0/S13: CloudSignInTip renders its label inside the rail's green pill
|
||||
badge (`.entry-local-mode-tip__login-badge`, global). In the share panel the
|
||||
design's buttons are plain text labels — no pill, no icon, inherited color. */
|
||||
.signInPrimaryAction :global(.entry-local-mode-tip__head),
|
||||
.signInSecondaryAction :global(.entry-local-mode-tip__head) {
|
||||
display: contents;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.signInPrimaryAction :global(.entry-local-mode-tip__login-badge),
|
||||
.signInSecondaryAction :global(.entry-local-mode-tip__login-badge) {
|
||||
flex: none;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.signInPrimaryAction :global(.entry-local-mode-tip__login-badge) > svg,
|
||||
.signInSecondaryAction :global(.entry-local-mode-tip__login-badge) > svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* S13: the design's `.share-status-panel .notice .secondary` — fixed 88px,
|
||||
borderless blue — same spec as .updateButton below. */
|
||||
.signInSecondaryAction {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
flex: none;
|
||||
flex: 0 0 88px;
|
||||
width: 88px;
|
||||
min-width: 88px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #EDEDF0;
|
||||
color: #333333;
|
||||
background: #EDF3FF;
|
||||
color: #245FCE;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -71,9 +100,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.signInSecondaryAction:hover:not(:global(.is-signing)) { background: #E3E3E7; }
|
||||
.signInSecondaryAction:focus-visible { outline: 2px solid #1677FF; outline-offset: 2px; }
|
||||
.signInSecondaryAction:global(.is-signing) { cursor: default; background: #E3E3E7; }
|
||||
.signInSecondaryAction:hover:not(:global(.is-signing)) { background: #E0EAFF; color: #194EAF; }
|
||||
.signInSecondaryAction:active:not(:global(.is-signing)) { background: #D3E1FF; color: #194EAF; }
|
||||
.signInSecondaryAction:focus-visible { outline: 2px solid #245FCE; outline-offset: 2px; }
|
||||
.signInSecondaryAction:global(.is-signing) { cursor: default; }
|
||||
|
||||
/* S1/S4: link access heading and description. */
|
||||
.linkAccessHeading {
|
||||
@@ -424,26 +454,26 @@ p.publishHint {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* S5/S13: the copy row precedes a bordered `.notice` box — description on the
|
||||
left, a `.secondary` action button on the right, matching the design's
|
||||
inline row rather than the previous stacked full-width button. */
|
||||
/* S5/S13: `.share-status-panel .notice` — UNBOXED (no border, fill, or
|
||||
padding): copy on the left, a fixed-width blue action on the right. */
|
||||
.updateNotice {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 8px 8px 10px;
|
||||
border: 1px solid #EFEFEF;
|
||||
border-radius: 6px;
|
||||
background: #F7F7F7;
|
||||
gap: 16px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.updateNotice p {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #666666;
|
||||
color: #5C5C5C;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -454,21 +484,35 @@ button.updateButton {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
flex: none;
|
||||
flex: 0 0 88px;
|
||||
width: 88px;
|
||||
min-width: 88px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #EDEDF0;
|
||||
color: #333333;
|
||||
background: #EDF3FF;
|
||||
color: #245FCE;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button.updateButton:hover:not(:disabled) {
|
||||
background: #E3E3E7;
|
||||
background: #E0EAFF;
|
||||
color: #194EAF;
|
||||
}
|
||||
|
||||
button.updateButton:active:not(:disabled) {
|
||||
background: #D3E1FF;
|
||||
color: #194EAF;
|
||||
}
|
||||
|
||||
button.updateButton:focus-visible {
|
||||
outline: 2px solid #245FCE;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.publishedLinkIcon {
|
||||
|
||||
@@ -41,6 +41,77 @@ function PublishProgressFrame({ value, label, children }: { value: number | null
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep copy/copied/copying icons and labels identical across every published state. */
|
||||
export function ShareCopyLinkIcon({ copying, copied }: { copying: boolean; copied: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox={!copying && copied ? '0 0 16 16' : '0 0 24 24'}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={copying ? 2 : 1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className={copying ? 'icon-spin' : copied ? styles.copiedIcon : undefined}
|
||||
>
|
||||
<path d={copying
|
||||
? 'M12 3a9 9 0 1 0 9 9'
|
||||
: copied
|
||||
? 'm3 8 3 3 7-7'
|
||||
: 'M10 13.5a5 5 0 0 0 7 .2l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 10.5a5 5 0 0 0-7-.2l-3 3a5 5 0 0 0 7 7l1.7-1.7'} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* S3/S4/S5/S13/K5: the one published-link block (chain-icon URL row + dark
|
||||
* copy button). Signed-in, signed-in-prompt, and the app-level signed-out
|
||||
* fallback all render this so a published link never changes shape with
|
||||
* session state.
|
||||
*/
|
||||
export function SharePublishedLinkControls({ url, copying, feedback, disabled = false, title, onCopy, t }: {
|
||||
url: string;
|
||||
copying: boolean;
|
||||
feedback: 'copied' | 'failed' | null;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
onCopy: () => void;
|
||||
t: ReturnType<typeof useT>;
|
||||
}) {
|
||||
const copied = !copying && feedback === 'copied';
|
||||
return (
|
||||
<div className={`chrome-publish-plain ${styles.publishedLink}`}>
|
||||
<div className={`chrome-publish-url ${styles.publishedUrl}`} title={url}>
|
||||
<RemixIcon name="link" size={12} className={styles.publishedLinkIcon} />
|
||||
{url}
|
||||
</div>
|
||||
<div className={`chrome-publish-actions ${styles.publishedActions}`}>
|
||||
<Button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
disabled={disabled || copying}
|
||||
aria-busy={copying || undefined}
|
||||
title={title}
|
||||
onClick={onCopy}
|
||||
>
|
||||
<ShareCopyLinkIcon copying={copying} copied={copied} />
|
||||
{copying
|
||||
? t('fileViewer.copyingLink')
|
||||
: copied
|
||||
? t('preview.shareCopied')
|
||||
: t('fileViewer.copyShareLink')}
|
||||
</Button>
|
||||
</div>
|
||||
{feedback === 'failed' ? (
|
||||
<p className={styles.copyHint} role="status">{t('fileViewer.copyLinkManually')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShareTab({
|
||||
menuOrigin,
|
||||
publicationStatus = null,
|
||||
@@ -254,52 +325,15 @@ export function ShareTab({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={`chrome-publish-plain ${styles.publishedLink}`}>
|
||||
<div className={`chrome-publish-url ${styles.publishedUrl}`} title={publishedFileUrl}>
|
||||
<RemixIcon name="link" size={12} className={styles.publishedLinkIcon} />
|
||||
{publishedFileUrl}
|
||||
</div>
|
||||
<div className={`chrome-publish-actions ${styles.publishedActions}`}>
|
||||
<Button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
disabled={streaming || copyingLink}
|
||||
aria-busy={copyingLink || undefined}
|
||||
title={streaming ? t('fileViewer.shareAfterGenerationComplete') : undefined}
|
||||
onClick={() => {
|
||||
void handleCopyPublishedFileLink();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox={!copyingLink && publishLinkFeedback === 'copied' ? '0 0 16 16' : '0 0 24 24'}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={copyingLink ? 2 : 1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className={copyingLink ? 'icon-spin' : publishLinkFeedback === 'copied' ? styles.copiedIcon : undefined}
|
||||
>
|
||||
<path d={copyingLink
|
||||
? 'M12 3a9 9 0 1 0 9 9'
|
||||
: publishLinkFeedback === 'copied'
|
||||
? 'm3 8 3 3 7-7'
|
||||
: 'M10 13.5a5 5 0 0 0 7 .2l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 10.5a5 5 0 0 0-7-.2l-3 3a5 5 0 0 0 7 7l1.7-1.7'} />
|
||||
</svg>
|
||||
{copyingLink
|
||||
? t('fileViewer.copyingLink')
|
||||
: publishLinkFeedback === 'copied'
|
||||
? t('preview.shareCopied')
|
||||
: t('fileViewer.copyShareLink')}
|
||||
</Button>
|
||||
</div>
|
||||
{publishLinkFeedback === 'failed' ? (
|
||||
<p className={styles.copyHint} role="status">{t('fileViewer.copyLinkManually')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<SharePublishedLinkControls
|
||||
url={publishedFileUrl}
|
||||
copying={copyingLink}
|
||||
feedback={publishLinkFeedback}
|
||||
disabled={streaming}
|
||||
title={streaming ? t('fileViewer.shareAfterGenerationComplete') : undefined}
|
||||
onCopy={() => { void handleCopyPublishedFileLink(); }}
|
||||
t={t}
|
||||
/>
|
||||
{!canMutatePublicShare && canResumeUpdateAfterLogin ? (
|
||||
<div className={styles.updateNotice}>
|
||||
<p>{t('fileViewer.shareOutdatedSignInHint')}</p>
|
||||
@@ -399,21 +433,21 @@ export function ShareTab({
|
||||
<p className={styles.linkAccessDescription}>{t(canResumeUpdateAfterLogin ? 'fileViewer.shareOutdatedSignInHint' : 'fileViewer.publishFileRequiresWorkspace')}</p>
|
||||
</div>
|
||||
{filePublished && publishedFileUrl ? (
|
||||
<div className={styles.publishedLink}>
|
||||
<span className="chrome-publish-url" title={publishedFileUrl}>{publishedFileUrl}</span>
|
||||
<Button type="button" className={styles.copyButton} disabled={streaming || copyingLink}
|
||||
aria-busy={copyingLink || undefined}
|
||||
onClick={() => { void handleCopyPublishedFileLink(); }}>
|
||||
{copyingLink ? t('fileViewer.copyingLink') : publishLinkFeedback === 'copied' ? t('fileViewer.copied') : t('fileViewer.copyShareLink')}
|
||||
</Button>
|
||||
</div>
|
||||
<SharePublishedLinkControls
|
||||
url={publishedFileUrl}
|
||||
copying={copyingLink}
|
||||
feedback={publishLinkFeedback}
|
||||
disabled={streaming}
|
||||
onCopy={() => { void handleCopyPublishedFileLink(); }}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
{/* S0: the sole full-width primary action when nothing has
|
||||
ever been shared. When a stale link is already shown above
|
||||
(the filePublished branch just above), this instead plays
|
||||
S13's secondary "sign in to update" role next to it. */}
|
||||
<CloudSignInTip sharePrompt className={filePublished && publishedFileUrl ? styles.signInSecondaryAction : styles.signInPrimaryAction}
|
||||
actionLabel={canResumeUpdateAfterLogin ? t('fileViewer.signInToUpdate') : undefined}
|
||||
actionLabel={canResumeUpdateAfterLogin ? t('fileViewer.signInToUpdate') : t('fileViewer.signInToShare')}
|
||||
onLoginSuccess={canResumeUpdateAfterLogin ? onUpdateLoginSuccess : undefined} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
.panel {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
/* minmax(0, …) lets the long URL row ellipsize instead of widening the card. */
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
width: min(100%, 360px);
|
||||
padding: 20px;
|
||||
@@ -25,49 +27,3 @@
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.url {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ededed;
|
||||
border-radius: 6px;
|
||||
color: #777;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.copy {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: #282828;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.updateNotice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.updateNotice p { margin: 0; }
|
||||
|
||||
.signInAction {
|
||||
flex: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: #f3f7ff;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signInAction :global(.entry-local-mode-tip__login-badge) {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@open-design/components';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useT } from '../../i18n';
|
||||
import styles from './SignedOutObservedShare.module.css';
|
||||
import shareStyles from './ShareTab.module.css';
|
||||
import { SharePublishedLinkControls } from './ShareTab';
|
||||
import { CloudSignInTip } from '../CloudSignInTip';
|
||||
|
||||
/** Only the previously observed public URL is visible; no project source is mounted. */
|
||||
/** Matches the signed-in panel's copied-feedback lifetime (design S3/S4-C: ~1.8s). */
|
||||
const COPIED_FEEDBACK_MS = 1800;
|
||||
|
||||
/**
|
||||
* S13: only the previously observed public URL is visible; no project source
|
||||
* is mounted. The link block and update notice are the SAME components and
|
||||
* classes the signed-in ShareTab renders, so losing the session never changes
|
||||
* what a published link looks like — only the toggle dims and the notice's
|
||||
* action becomes "sign in to update".
|
||||
*/
|
||||
export function SignedOutObservedShare({ url, canUpdate, onLoginUpdateSuccess }: {
|
||||
url: string; canUpdate: boolean; onLoginUpdateSuccess: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [feedback, setFeedback] = useState<'copied' | 'failed' | null>(null);
|
||||
const resetTimer = useRef<number | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (resetTimer.current !== null) window.clearTimeout(resetTimer.current);
|
||||
}, []);
|
||||
|
||||
function showFeedback(next: 'copied' | 'failed') {
|
||||
setFeedback(next);
|
||||
if (resetTimer.current !== null) window.clearTimeout(resetTimer.current);
|
||||
if (next === 'copied') {
|
||||
resetTimer.current = window.setTimeout(() => {
|
||||
resetTimer.current = null;
|
||||
setFeedback(null);
|
||||
}, COPIED_FEEDBACK_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function copy() {
|
||||
if (copying) return;
|
||||
if (!navigator.clipboard?.writeText) { showFeedback('failed'); return; }
|
||||
setCopying(true);
|
||||
void navigator.clipboard.writeText(url).then(
|
||||
() => showFeedback('copied'),
|
||||
() => showFeedback('failed'),
|
||||
).finally(() => setCopying(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.shell} role="status">
|
||||
<div className={styles.panel}>
|
||||
@@ -25,20 +61,10 @@ export function SignedOutObservedShare({ url, canUpdate, onLoginUpdateSuccess }:
|
||||
</div>
|
||||
<p className={shareStyles.linkAccessDescription}>{t('fileViewer.linkAccessDescription')}</p>
|
||||
</div>
|
||||
<code className={styles.url}>{url}</code>
|
||||
<Button type="button" className={styles.copy} onClick={() => {
|
||||
if (!navigator.clipboard?.writeText) { setCopyState('failed'); return; }
|
||||
void navigator.clipboard.writeText(url).then(
|
||||
() => setCopyState('copied'),
|
||||
() => setCopyState('failed'),
|
||||
);
|
||||
}}>
|
||||
{copyState === 'copied' ? t('fileViewer.copied') : t('fileViewer.copyShareLink')}
|
||||
</Button>
|
||||
{copyState === 'failed' ? <p role="alert">{t('fileViewer.copyLinkManually')}</p> : null}
|
||||
{canUpdate ? <div className={styles.updateNotice}>
|
||||
<SharePublishedLinkControls url={url} copying={copying} feedback={feedback} onCopy={copy} t={t} />
|
||||
{canUpdate ? <div className={shareStyles.updateNotice}>
|
||||
<p>{t('fileViewer.shareOutdatedSignInHint')}</p>
|
||||
<CloudSignInTip sharePrompt className={styles.signInAction} actionLabel={t('fileViewer.signInToUpdate')} onLoginSuccess={onLoginUpdateSuccess} />
|
||||
<CloudSignInTip sharePrompt className={shareStyles.signInSecondaryAction} actionLabel={t('fileViewer.signInToUpdate')} onLoginSuccess={onLoginUpdateSuccess} />
|
||||
</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3305,6 +3305,7 @@ export const ar: Dict = {
|
||||
"fileViewer.shareUpdateHint": "يحتوي الملف على تغييرات جديدة. حدّث الرابط لمشاركة أحدث إصدار.",
|
||||
"fileViewer.shareUpdateLink": "تحديث الرابط",
|
||||
"fileViewer.signInToUpdate": "سجّل الدخول للتحديث",
|
||||
"fileViewer.signInToShare": "سجّل الدخول للمشاركة",
|
||||
"fileViewer.shareOutdatedSignInHint": "يحتوي هذا الملف على تغييرات جديدة. سجّل الدخول لتحديث الرابط أو إيقافه.",
|
||||
"fileViewer.shareUpdateSuccess": "تم التحديث. سيرى الزوار أحدث إصدار بعد تحديث الصفحة.",
|
||||
"fileViewer.shareUpdateFailed": "فشل التحديث. لا يزال الزوار يرون الإصدار السابق.",
|
||||
|
||||
@@ -3305,6 +3305,7 @@ export const de: Dict = {
|
||||
"fileViewer.shareUpdateHint": "Diese Datei enthält neue Änderungen. Aktualisiere den Link für die neueste Version.",
|
||||
"fileViewer.shareUpdateLink": "Link aktualisieren",
|
||||
"fileViewer.signInToUpdate": "Anmelden und aktualisieren",
|
||||
"fileViewer.signInToShare": "Anmelden und teilen",
|
||||
"fileViewer.shareOutdatedSignInHint": "Diese Datei wurde geändert. Melde dich an, um den Link zu aktualisieren oder zu deaktivieren.",
|
||||
"fileViewer.shareUpdateSuccess": "Aktualisiert. Besucher sehen die neueste Version nach dem Neuladen.",
|
||||
"fileViewer.shareUpdateFailed": "Aktualisierung fehlgeschlagen. Besucher sehen weiterhin die vorherige Version.",
|
||||
|
||||
@@ -3319,6 +3319,7 @@ export const en: Dict = {
|
||||
"fileViewer.shareUpdateHint": "This file has new changes. Update the link to share the latest version.",
|
||||
"fileViewer.shareUpdateLink": "Update link",
|
||||
"fileViewer.signInToUpdate": "Sign in to update",
|
||||
"fileViewer.signInToShare": "Sign in to share",
|
||||
"fileViewer.shareOutdatedSignInHint": "This file has new changes. Sign in to update or turn off the link.",
|
||||
"fileViewer.shareUpdateSuccess": "Updated. Visitors will see the latest version after refreshing.",
|
||||
"fileViewer.shareUpdateFailed": "Update failed. Visitors still see the previous version.",
|
||||
|
||||
@@ -3305,6 +3305,7 @@ export const esES: Dict = {
|
||||
"fileViewer.shareUpdateHint": "Este archivo tiene cambios nuevos. Actualiza el enlace para compartir la última versión.",
|
||||
"fileViewer.shareUpdateLink": "Actualizar enlace",
|
||||
"fileViewer.signInToUpdate": "Iniciar sesión para actualizar",
|
||||
"fileViewer.signInToShare": "Iniciar sesión para compartir",
|
||||
"fileViewer.shareOutdatedSignInHint": "Este archivo tiene cambios nuevos. Inicia sesión para actualizar o desactivar el enlace.",
|
||||
"fileViewer.shareUpdateSuccess": "Actualizado. Los visitantes verán la última versión al actualizar la página.",
|
||||
"fileViewer.shareUpdateFailed": "Error al actualizar. Los visitantes siguen viendo la versión anterior.",
|
||||
|
||||
@@ -3305,6 +3305,7 @@ export const fa: Dict = {
|
||||
"fileViewer.shareUpdateHint": "این فایل تغییرات جدیدی دارد. پیوند را برای اشتراکگذاری آخرین نسخه بهروزرسانی کنید.",
|
||||
"fileViewer.shareUpdateLink": "بهروزرسانی پیوند",
|
||||
"fileViewer.signInToUpdate": "ورود برای بهروزرسانی",
|
||||
"fileViewer.signInToShare": "ورود برای اشتراکگذاری",
|
||||
"fileViewer.shareOutdatedSignInHint": "این فایل تغییرات جدیدی دارد. برای بهروزرسانی یا غیرفعال کردن پیوند وارد شوید.",
|
||||
"fileViewer.shareUpdateSuccess": "بهروزرسانی شد. بازدیدکنندگان پس از تازهسازی آخرین نسخه را میبینند.",
|
||||
"fileViewer.shareUpdateFailed": "بهروزرسانی ناموفق بود. بازدیدکنندگان همچنان نسخه قبلی را میبینند.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const fr: Dict = {
|
||||
"fileViewer.shareUpdateHint": "Ce fichier a changé. Mettez le lien à jour pour partager la dernière version.",
|
||||
"fileViewer.shareUpdateLink": "Mettre à jour le lien",
|
||||
"fileViewer.signInToUpdate": "Se connecter pour mettre à jour",
|
||||
"fileViewer.signInToShare": "Se connecter pour partager",
|
||||
"fileViewer.shareOutdatedSignInHint": "Ce fichier a changé. Connectez-vous pour mettre à jour ou désactiver le lien.",
|
||||
"fileViewer.shareUpdateSuccess": "Mis à jour. Les visiteurs verront la dernière version après actualisation.",
|
||||
"fileViewer.shareUpdateFailed": "Échec de la mise à jour. Les visiteurs voient toujours la version précédente.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const hu: Dict = {
|
||||
"fileViewer.shareUpdateHint": "A fájl megváltozott. Frissítsd a hivatkozást a legújabb verzió megosztásához.",
|
||||
"fileViewer.shareUpdateLink": "Hivatkozás frissítése",
|
||||
"fileViewer.signInToUpdate": "Bejelentkezés a frissítéshez",
|
||||
"fileViewer.signInToShare": "Bejelentkezés a megosztáshoz",
|
||||
"fileViewer.shareOutdatedSignInHint": "A fájl megváltozott. Jelentkezz be a hivatkozás frissítéséhez vagy kikapcsolásához.",
|
||||
"fileViewer.shareUpdateSuccess": "Frissítve. A látogatók frissítés után látják a legújabb verziót.",
|
||||
"fileViewer.shareUpdateFailed": "A frissítés sikertelen. A látogatók továbbra is az előző verziót látják.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const id: Dict = {
|
||||
"fileViewer.shareUpdateHint": "Berkas ini memiliki perubahan baru. Perbarui tautan untuk membagikan versi terbaru.",
|
||||
"fileViewer.shareUpdateLink": "Perbarui tautan",
|
||||
"fileViewer.signInToUpdate": "Masuk untuk memperbarui",
|
||||
"fileViewer.signInToShare": "Masuk untuk berbagi",
|
||||
"fileViewer.shareOutdatedSignInHint": "File ini memiliki perubahan baru. Masuk untuk memperbarui atau menonaktifkan tautan.",
|
||||
"fileViewer.shareUpdateSuccess": "Diperbarui. Pengunjung akan melihat versi terbaru setelah memuat ulang.",
|
||||
"fileViewer.shareUpdateFailed": "Pembaruan gagal. Pengunjung masih melihat versi sebelumnya.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const it: Dict = {
|
||||
"fileViewer.shareUpdateHint": "Questo file contiene nuove modifiche. Aggiorna il link per condividere la versione più recente.",
|
||||
"fileViewer.shareUpdateLink": "Aggiorna link",
|
||||
"fileViewer.signInToUpdate": "Accedi per aggiornare",
|
||||
"fileViewer.signInToShare": "Accedi per condividere",
|
||||
"fileViewer.shareOutdatedSignInHint": "Questo file ha nuove modifiche. Accedi per aggiornare o disattivare il link.",
|
||||
"fileViewer.shareUpdateSuccess": "Aggiornato. I visitatori vedranno l'ultima versione dopo l'aggiornamento della pagina.",
|
||||
"fileViewer.shareUpdateFailed": "Aggiornamento non riuscito. I visitatori vedono ancora la versione precedente.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const ja: Dict = {
|
||||
"fileViewer.shareUpdateHint": "このファイルに新しい変更があります。リンクを更新して最新版を共有してください。",
|
||||
"fileViewer.shareUpdateLink": "リンクを更新",
|
||||
"fileViewer.signInToUpdate": "ログインして更新",
|
||||
"fileViewer.signInToShare": "ログインして共有",
|
||||
"fileViewer.shareOutdatedSignInHint": "ファイルに新しい変更があります。ログインするとリンクを更新または停止できます。",
|
||||
"fileViewer.shareUpdateSuccess": "更新しました。訪問者は再読み込み後に最新版を確認できます。",
|
||||
"fileViewer.shareUpdateFailed": "更新に失敗しました。訪問者には引き続き前のバージョンが表示されます。",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const ko: Dict = {
|
||||
"fileViewer.shareUpdateHint": "이 파일에 새 변경 사항이 있습니다. 링크를 업데이트하여 최신 버전을 공유하세요.",
|
||||
"fileViewer.shareUpdateLink": "링크 업데이트",
|
||||
"fileViewer.signInToUpdate": "로그인 후 업데이트",
|
||||
"fileViewer.signInToShare": "로그인 후 공유",
|
||||
"fileViewer.shareOutdatedSignInHint": "파일에 새 변경 사항이 있습니다. 로그인한 후 링크를 업데이트하거나 비활성화할 수 있습니다.",
|
||||
"fileViewer.shareUpdateSuccess": "업데이트되었습니다. 방문자가 새로고침하면 최신 버전을 볼 수 있습니다.",
|
||||
"fileViewer.shareUpdateFailed": "업데이트에 실패했습니다. 방문자에게는 여전히 이전 버전이 표시됩니다.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const pl: Dict = {
|
||||
"fileViewer.shareUpdateHint": "Plik zawiera nowe zmiany. Zaktualizuj link, aby udostępnić najnowszą wersję.",
|
||||
"fileViewer.shareUpdateLink": "Zaktualizuj link",
|
||||
"fileViewer.signInToUpdate": "Zaloguj się, aby zaktualizować",
|
||||
"fileViewer.signInToShare": "Zaloguj się, aby udostępnić",
|
||||
"fileViewer.shareOutdatedSignInHint": "Ten plik ma nowe zmiany. Zaloguj się, aby zaktualizować lub wyłączyć link.",
|
||||
"fileViewer.shareUpdateSuccess": "Zaktualizowano. Odwiedzający zobaczą najnowszą wersję po odświeżeniu.",
|
||||
"fileViewer.shareUpdateFailed": "Aktualizacja nie powiodła się. Odwiedzający nadal widzą poprzednią wersję.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const ptBR: Dict = {
|
||||
"fileViewer.shareUpdateHint": "Este arquivo tem novas alterações. Atualize o link para compartilhar a versão mais recente.",
|
||||
"fileViewer.shareUpdateLink": "Atualizar link",
|
||||
"fileViewer.signInToUpdate": "Entre para atualizar",
|
||||
"fileViewer.signInToShare": "Entre para compartilhar",
|
||||
"fileViewer.shareOutdatedSignInHint": "Este arquivo tem novas alterações. Entre para atualizar ou desativar o link.",
|
||||
"fileViewer.shareUpdateSuccess": "Atualizado. Os visitantes verão a versão mais recente após atualizar a página.",
|
||||
"fileViewer.shareUpdateFailed": "Falha ao atualizar. Os visitantes ainda veem a versão anterior.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const ru: Dict = {
|
||||
"fileViewer.shareUpdateHint": "В файле есть новые изменения. Обновите ссылку, чтобы поделиться последней версией.",
|
||||
"fileViewer.shareUpdateLink": "Обновить ссылку",
|
||||
"fileViewer.signInToUpdate": "Войти и обновить",
|
||||
"fileViewer.signInToShare": "Войти и поделиться",
|
||||
"fileViewer.shareOutdatedSignInHint": "В файле есть изменения. Войдите, чтобы обновить или отключить ссылку.",
|
||||
"fileViewer.shareUpdateSuccess": "Обновлено. Посетители увидят последнюю версию после обновления страницы.",
|
||||
"fileViewer.shareUpdateFailed": "Не удалось обновить. Посетители по-прежнему видят предыдущую версию.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const th: Dict = {
|
||||
"fileViewer.shareUpdateHint": "ไฟล์นี้มีการเปลี่ยนแปลงใหม่ อัปเดตลิงก์เพื่อแชร์เวอร์ชันล่าสุด",
|
||||
"fileViewer.shareUpdateLink": "อัปเดตลิงก์",
|
||||
"fileViewer.signInToUpdate": "เข้าสู่ระบบเพื่ออัปเดต",
|
||||
"fileViewer.signInToShare": "เข้าสู่ระบบเพื่อแชร์",
|
||||
"fileViewer.shareOutdatedSignInHint": "ไฟล์นี้มีการเปลี่ยนแปลงใหม่ เข้าสู่ระบบเพื่ออัปเดตหรือปิดลิงก์",
|
||||
"fileViewer.shareUpdateSuccess": "อัปเดตแล้ว ผู้เยี่ยมชมจะเห็นเวอร์ชันล่าสุดหลังรีเฟรช",
|
||||
"fileViewer.shareUpdateFailed": "อัปเดตไม่สำเร็จ ผู้เยี่ยมชมยังคงเห็นเวอร์ชันก่อนหน้า",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const tr: Dict = {
|
||||
"fileViewer.shareUpdateHint": "Bu dosyada yeni değişiklikler var. En son sürümü paylaşmak için bağlantıyı güncelleyin.",
|
||||
"fileViewer.shareUpdateLink": "Bağlantıyı güncelle",
|
||||
"fileViewer.signInToUpdate": "Giriş yapıp güncelle",
|
||||
"fileViewer.signInToShare": "Giriş yapıp paylaş",
|
||||
"fileViewer.shareOutdatedSignInHint": "Bu dosyada yeni değişiklikler var. Bağlantıyı güncellemek veya kapatmak için giriş yapın.",
|
||||
"fileViewer.shareUpdateSuccess": "Güncellendi. Ziyaretçiler sayfayı yenilediğinde en son sürümü görecek.",
|
||||
"fileViewer.shareUpdateFailed": "Güncelleme başarısız oldu. Ziyaretçiler hâlâ önceki sürümü görüyor.",
|
||||
|
||||
@@ -3297,6 +3297,7 @@ export const uk: Dict = {
|
||||
"fileViewer.shareUpdateHint": "У файлі є нові зміни. Оновіть посилання, щоб поділитися останньою версією.",
|
||||
"fileViewer.shareUpdateLink": "Оновити посилання",
|
||||
"fileViewer.signInToUpdate": "Увійти для оновлення",
|
||||
"fileViewer.signInToShare": "Увійти й поділитися",
|
||||
"fileViewer.shareOutdatedSignInHint": "У файлі є нові зміни. Увійдіть, щоб оновити або вимкнути посилання.",
|
||||
"fileViewer.shareUpdateSuccess": "Оновлено. Відвідувачі побачать останню версію після оновлення сторінки.",
|
||||
"fileViewer.shareUpdateFailed": "Не вдалося оновити. Відвідувачі й далі бачать попередню версію.",
|
||||
|
||||
@@ -3469,6 +3469,7 @@ export const zhCN: Dict = {
|
||||
"fileViewer.shareUpdateHint": "文件有新改动。更新链接以分享最新版本。",
|
||||
"fileViewer.shareUpdateLink": "更新链接",
|
||||
"fileViewer.signInToUpdate": "登录后更新",
|
||||
"fileViewer.signInToShare": "登录后分享",
|
||||
"fileViewer.shareOutdatedSignInHint": "文件有新改动,登录后可更新或关闭链接。",
|
||||
"fileViewer.shareUpdateSuccess": "已更新,访问者刷新后可看到最新版本",
|
||||
"fileViewer.shareUpdateFailed": "更新失败,访问者仍看到上一版",
|
||||
|
||||
@@ -3478,6 +3478,7 @@ export const zhTW: Dict = {
|
||||
"fileViewer.shareUpdateHint": "檔案有新變更。更新連結以分享最新版本。",
|
||||
"fileViewer.shareUpdateLink": "更新連結",
|
||||
"fileViewer.signInToUpdate": "登入後更新",
|
||||
"fileViewer.signInToShare": "登入後分享",
|
||||
"fileViewer.shareOutdatedSignInHint": "檔案有新變更,登入後可更新或關閉連結。",
|
||||
"fileViewer.shareUpdateSuccess": "已更新,訪客重新整理後可看到最新版本",
|
||||
"fileViewer.shareUpdateFailed": "更新失敗,訪客仍看到上一版",
|
||||
|
||||
@@ -4228,6 +4228,7 @@ export interface Dict {
|
||||
'fileViewer.shareUpdateHint': string;
|
||||
'fileViewer.shareUpdateLink': string;
|
||||
'fileViewer.signInToUpdate': string;
|
||||
'fileViewer.signInToShare': string;
|
||||
'fileViewer.shareOutdatedSignInHint': string;
|
||||
'fileViewer.shareUpdateSuccess': string;
|
||||
'fileViewer.shareUpdateFailed': string;
|
||||
|
||||
@@ -130,78 +130,6 @@
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* Scoped dark pill for share updates and deletion residual feedback. */
|
||||
.od-toast.od-toast-share-feedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
width: max-content;
|
||||
max-width: min(480px, calc(100vw - 32px));
|
||||
padding: 6px 42px 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: #202020;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.od-toast.od-toast-share-feedback .od-toast-body {
|
||||
min-width: 0;
|
||||
}
|
||||
.od-toast.od-toast-share-feedback .od-toast-details {
|
||||
flex: 0 0 100%;
|
||||
order: 3;
|
||||
}
|
||||
.od-toast.od-toast-share-feedback .od-toast-message {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
/* Design (S6-OK/S6-ERR/S14-ERR): the leading glyph is a solid colored circle
|
||||
badge, not a bare icon — green check for success, red "!" for error. */
|
||||
.od-toast.od-toast-share-feedback .od-toast-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
}
|
||||
.od-toast.od-toast-share-feedback.tone-success .od-toast-icon {
|
||||
background: #4C9461;
|
||||
}
|
||||
.od-toast.od-toast-share-feedback.tone-error .od-toast-icon {
|
||||
background: #E5484D;
|
||||
}
|
||||
.od-toast.od-toast-share-feedback .od-toast-action {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.od-toast.od-toast-share-feedback .od-toast-action:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
.od-toast.od-toast-share-feedback .od-toast-close {
|
||||
color: #fff;
|
||||
}
|
||||
.od-toast.od-toast-share-feedback.tone-success,
|
||||
.od-toast.od-toast-share-feedback.tone-error {
|
||||
border: 0;
|
||||
background: #202020;
|
||||
color: #fff;
|
||||
}
|
||||
.od-toast.od-toast-share-feedback.tone-success .od-toast-icon {
|
||||
color: inherit;
|
||||
}
|
||||
.workspace.has-browser-snapshot-toast {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ it('renders real deletion failures as global Toast, queues each file, and never
|
||||
render(<Harness />);
|
||||
await act(async () => fireEvent.click(screen.getByText('Delete')));
|
||||
const failure = screen.getByRole('alert');
|
||||
expect(failure).toHaveClass('od-toast', 'tone-error', 'placement-top');
|
||||
expect(failure).toHaveAttribute('data-tone', 'error');
|
||||
expect(failure.parentElement).toBe(document.body);
|
||||
expect(within(failure).getByText(/manual\.html.*could not be stopped/i)).toBeVisible();
|
||||
expect(within(failure).getByRole('button', { name: 'Retry' })).toBeEnabled();
|
||||
expect(screen.queryByText('auto.html')).toBeNull();
|
||||
|
||||
@@ -146,6 +146,10 @@ describe('CommentSyncBanner — restored branches', () => {
|
||||
renderBanner({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'resume-r2', retryable: true, reopened: true } });
|
||||
const retry = await screen.findByRole('button', { name: '重试' });
|
||||
expect(retry.className).toMatch(/retryError/);
|
||||
// Round-3 audit (K5): the design's retry glyph precedes the label.
|
||||
const glyph = retry.querySelector('svg[aria-hidden="true"] path');
|
||||
expect(glyph).toHaveAttribute('d', 'M20 11a8 8 0 0 0-14.3-4.9L4 8M4 4v4h4M4 13a8 8 0 0 0 14.3 4.9L20 16m0 4v-4h-4');
|
||||
expect(retry.firstElementChild?.tagName.toLowerCase()).toBe('svg');
|
||||
});
|
||||
|
||||
it('keeps the plain (non-red) retry style for a retryable non-reopened backfill failure', async () => {
|
||||
|
||||
@@ -42,61 +42,51 @@ describe('Owner S12 deployment menu layering', () => {
|
||||
});
|
||||
|
||||
describe('Owner S12 deployment menu', () => {
|
||||
it('keeps deployment items in the viewport beside an edge trigger without removing the link-access explanation', () => {
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 320 });
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 700 });
|
||||
const selectVercel = vi.fn();
|
||||
render(<><ShareMoreMenu label="More sharing options" items={[
|
||||
{ id: 'vercel', label: 'Deploy to Vercel', onSelect: selectVercel },
|
||||
const rect = (left: number, top: number, width: number, height: number) => ({
|
||||
x: left, y: top, left, top, right: left + width, bottom: top + height, width, height, toJSON: () => ({}),
|
||||
});
|
||||
function renderInHeader(onSelect = vi.fn()) {
|
||||
render(<div data-testid="tools"><ShareMoreMenu label="More sharing options" items={[
|
||||
{ id: 'vercel', label: 'Deploy to Vercel', onSelect },
|
||||
{ id: 'cloudflare', label: 'Deploy to Cloudflare Pages', onSelect: () => undefined },
|
||||
]} /><p>Link access details</p></>);
|
||||
const trigger = screen.getByRole('button', { name: 'More sharing options' });
|
||||
const triggerRect = vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 10, y: 40, left: 10, top: 40, right: 30, bottom: 60, width: 20, height: 20,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
]} /><button type="button">Close</button></div>);
|
||||
return { trigger: screen.getByRole('button', { name: 'More sharing options' }), tools: screen.getByTestId('tools') };
|
||||
}
|
||||
|
||||
it('drops down under the trigger, right-aligned to the header tools, over the panel content', () => {
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1440 });
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 });
|
||||
const selectVercel = vi.fn();
|
||||
const { trigger, tools } = renderInHeader(selectVercel);
|
||||
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(rect(1236, 100, 20, 20));
|
||||
vi.spyOn(tools, 'getBoundingClientRect').mockReturnValue(rect(1236, 100, 44, 20));
|
||||
fireEvent.click(trigger);
|
||||
const menu = screen.getByRole('menu');
|
||||
expect(screen.getByRole('menuitem', { name: 'Deploy to Vercel' })).toBeVisible();
|
||||
expect(screen.getByRole('menuitem', { name: 'Deploy to Cloudflare Pages' })).toBeVisible();
|
||||
expect(menu.style.position).toBe('fixed');
|
||||
expect(Number.parseFloat(menu.style.left)).toBeGreaterThanOrEqual(8);
|
||||
expect(Number.parseFloat(menu.style.left) + Number.parseFloat(menu.style.width)).toBeLessThanOrEqual(312);
|
||||
expect(menu.style.left).toBe('34px');
|
||||
expect(screen.getByText('Link access details')).toBeVisible();
|
||||
expect(menu.style.top).toBe('126px');
|
||||
// Right edge (left + 214) lands on the tools' right edge (1280).
|
||||
expect(menu.style.left).toBe('1066px');
|
||||
expect(menu.style.width).toBe('214px');
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Deploy to Vercel' }));
|
||||
expect(selectVercel).toHaveBeenCalledOnce();
|
||||
expect(trigger).toHaveFocus();
|
||||
triggerRect.mockReturnValue({
|
||||
x: 290, y: 40, left: 290, top: 40, right: 310, bottom: 60, width: 20, height: 20,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole('menu').style.left).toBe('72px');
|
||||
});
|
||||
|
||||
it('opens beside the panel instead of covering link-access copy when both sides fit', () => {
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 960 });
|
||||
render(<><ShareMoreMenu label="More sharing options" items={[
|
||||
{ id: 'vercel', label: 'Deploy to Vercel', onSelect: () => undefined },
|
||||
{ id: 'cloudflare', label: 'Deploy to Cloudflare Pages', onSelect: () => undefined },
|
||||
]} /><p>Link access details</p></>);
|
||||
const trigger = screen.getByRole('button', { name: 'More sharing options' });
|
||||
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 500, y: 40, left: 500, top: 40, right: 520, bottom: 60, width: 20, height: 20,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const explanation = screen.getByText('Link access details');
|
||||
vi.spyOn(explanation, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 280, y: 65, left: 280, top: 65, right: 490, bottom: 90, width: 210, height: 25,
|
||||
toJSON: () => ({}),
|
||||
it('stays inside a narrow viewport and flips above when there is no room below', () => {
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 200 });
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 130 });
|
||||
const { trigger, tools } = renderInHeader();
|
||||
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(rect(140, 100, 20, 20));
|
||||
vi.spyOn(tools, 'getBoundingClientRect').mockReturnValue(rect(140, 100, 44, 20));
|
||||
const menuRect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect');
|
||||
menuRect.mockImplementation(function (this: HTMLElement) {
|
||||
return this.getAttribute('role') === 'menu' ? rect(0, 0, 184, 64) : rect(0, 0, 0, 0);
|
||||
});
|
||||
fireEvent.click(trigger);
|
||||
const menu = screen.getByRole('menu');
|
||||
const menuLeft = Number.parseFloat(menu.style.left);
|
||||
expect(menuLeft).toBeGreaterThanOrEqual(explanation.getBoundingClientRect().right + 4);
|
||||
expect(menuLeft).toBe(524);
|
||||
expect(menuLeft + Number.parseFloat(menu.style.width)).toBeLessThanOrEqual(952);
|
||||
expect(menu.style.left).toBe('8px');
|
||||
expect(menu.style.width).toBe('184px');
|
||||
expect(menu.style.top).toBe('30px');
|
||||
});
|
||||
|
||||
it('dismisses with Escape and outside pointer, restoring focus on Escape', () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('S0/S13 signed-out Share panel', () => {
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const input = props({ canPublishPublic: false, filePublished: false, publishedFileUrl: '' });
|
||||
render(<I18nProvider initial="en"><ShareTab {...input} /></I18nProvider>);
|
||||
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'fileViewer.signInToShare' }));
|
||||
await waitFor(() => expect(fetch.mock.calls.filter(([url, init]) => String(url).endsWith('/api/integrations/vela/login') && init?.method === 'POST')).toHaveLength(1));
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel sign-in/i }));
|
||||
await waitFor(() => expect(fetch.mock.calls.filter(([url, init]) => String(url).endsWith('/api/integrations/vela/login/cancel') && init?.method === 'POST')).toHaveLength(1));
|
||||
@@ -61,7 +61,7 @@ describe('S0/S13 signed-out Share panel', () => {
|
||||
const input = props({ canPublishPublic: false, filePublished: false, publishedFileUrl: '' });
|
||||
render(<I18nProvider><ShareTab {...input} /></I18nProvider>);
|
||||
expect(screen.getByText('fileViewer.publishFileRequiresWorkspace')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'fileViewer.signInToShare' })).toBeEnabled();
|
||||
expect(screen.queryByRole('switch', { name: 'fileViewer.linkAccessTitle' })).toBeNull();
|
||||
expect(input.publishCurrentFilePublic).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Round-3 UI audit (S13): the app-level signed-out fallback must render the
|
||||
* SAME published-link block as the signed-in ShareTab — chain-icon URL row,
|
||||
* dark copy button, green check + 「已复制」 — not a bespoke monospace/grey
|
||||
* variant with the legacy 「已复制!」 label.
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SignedOutObservedShare } from '../../../src/components/share/SignedOutObservedShare';
|
||||
import { I18nProvider } from '../../../src/i18n';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const url = 'https://viewer.example.test/cloud/artifact/project/s13';
|
||||
|
||||
function renderFallback(canUpdate = true) {
|
||||
return render(
|
||||
<I18nProvider initial="zh-CN">
|
||||
<SignedOutObservedShare url={url} canUpdate={canUpdate} onLoginUpdateSuccess={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('S13 signed-out observed share', () => {
|
||||
it('renders the shared chain-icon URL row and the design copy/update actions', () => {
|
||||
const { container } = renderFallback();
|
||||
const row = container.querySelector('.chrome-publish-plain .chrome-publish-url');
|
||||
expect(row).toHaveTextContent(url);
|
||||
expect(row?.querySelector('svg')).not.toBeNull();
|
||||
expect(container.querySelector('code')).toBeNull();
|
||||
expect(screen.getByRole('switch', { name: '链接访问' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '复制链接' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: '登录后更新' })).toBeVisible();
|
||||
expect(screen.getByText('文件有新改动,登录后可更新或关闭链接。')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows the green-check 「已复制」 state after copying, then restores the label', async () => {
|
||||
vi.useFakeTimers();
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } });
|
||||
renderFallback(false);
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制链接' })); });
|
||||
expect(writeText).toHaveBeenCalledWith(url);
|
||||
const copied = screen.getByRole('button', { name: '已复制' });
|
||||
expect(copied.querySelector('svg path')).toHaveAttribute('d', 'm3 8 3 3 7-7');
|
||||
expect(copied).toBeEnabled();
|
||||
await act(async () => { vi.advanceTimersByTime(1800); });
|
||||
expect(screen.getByRole('button', { name: '复制链接' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
<div class="_signInPrompt_f3ed19"><div class="_linkAccessHeading_f3ed19"><span class="_linkAccessLabel_f3ed19">Link access</span><p class="_linkAccessDescription_f3ed19">A share link needs a signed-in workspace. Sign in to OpenDesign Cloud, or use a deploy option to share this file.</p></div><section role="button" tabindex="0" class="_signInPrimaryAction_f3ed19" aria-label="Sign in" data-testid="share-cloud-signin"><div class="entry-local-mode-tip__head"><span class="entry-local-mode-tip__login-badge"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false" class="od-icon"><path d="M10 11V8L15 12L10 16V13H1V11H10ZM2.4578 15H4.58152C5.76829 17.9318 8.64262 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C8.64262 4 5.76829 6.06817 4.58152 9H2.4578C3.73207 4.94289 7.52236 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C7.52236 22 3.73207 19.0571 2.4578 15Z"></path></svg>Sign in</span></div></section></div>
|
||||
<div class="_signInPrompt_f3ed19"><div class="_linkAccessHeading_f3ed19"><span class="_linkAccessLabel_f3ed19">Link access</span><p class="_linkAccessDescription_f3ed19">A share link needs a signed-in workspace. Sign in to OpenDesign Cloud, or use a deploy option to share this file.</p></div><section role="button" tabindex="0" class="_signInPrimaryAction_f3ed19" aria-label="Sign in to share" data-testid="share-cloud-signin"><div class="entry-local-mode-tip__head"><span class="entry-local-mode-tip__login-badge"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false" class="od-icon"><path d="M10 11V8L15 12L10 16V13H1V11H10ZM2.4578 15H4.58152C5.76829 17.9318 8.64262 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C8.64262 4 5.76829 6.06817 4.58152 9H2.4578C3.73207 4.94289 7.52236 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C7.52236 22 3.73207 19.0571 2.4578 15Z"></path></svg>Sign in to share</span></div></section></div>
|
||||
@@ -111,7 +111,7 @@ test('capture remaining OD share audit states from visible product UI', async ({
|
||||
await expect(update).toHaveAttribute('aria-busy', 'true');
|
||||
await capture(page, 'S6');
|
||||
finish();
|
||||
await expect(page.getByRole('status').filter({ hasText: /已更新|updated/i })).toBeVisible();
|
||||
await expect(page.getByRole('status').filter({ hasText: '已更新,访问者刷新后可看到最新版本' })).toBeVisible();
|
||||
await capture(page, 'S6-OK');
|
||||
|
||||
// Re-arm the real update affordance and fail that user action (not a
|
||||
@@ -119,7 +119,11 @@ test('capture remaining OD share audit states from visible product UI', async ({
|
||||
await page.route(publishPath, route => route.request().method() === 'GET'
|
||||
? route.fulfill({ json: { status: 'active', freshness: 'outdated', publication: { url: oldUrl, slug: 'prior-link', fileName: 'index.html' } } })
|
||||
: route.request().method() === 'POST'
|
||||
? route.fulfill({ status: 500, json: { error: { code: 'ui_audit_update_failed', message: 'Audit update failure' } } })
|
||||
// A push-stage failure is the only one where the remote alias cannot
|
||||
// have moved; that is the retryable S6-ERR state in the design. Other
|
||||
// stages render the separate "outcome unknown" safety copy.
|
||||
? route.fulfill({ status: 500, json: { error: { code: 'ui_audit_update_failed', message: 'Audit update failure' },
|
||||
failure: { stage: 'push', reason: 'upstream_http', upstreamStatus: 502 } } })
|
||||
: route.fallback());
|
||||
await page.reload();
|
||||
await page.getByTestId('artifact-card-publish-index.html').click();
|
||||
@@ -127,7 +131,9 @@ test('capture remaining OD share audit states from visible product UI', async ({
|
||||
const retryUpdate = retryShare.getByRole('button', { name: /更新链接|Update link/ });
|
||||
await expect(retryUpdate).toBeVisible();
|
||||
await retryUpdate.click();
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
const failure = page.getByRole('alert').filter({ hasText: '更新失败,访问者仍看到上一版' });
|
||||
await expect(failure).toBeVisible();
|
||||
await expect(failure.getByRole('button', { name: '重试', exact: true })).toBeVisible();
|
||||
await capture(page, 'S6-ERR');
|
||||
});
|
||||
|
||||
|
||||
@@ -117,7 +117,8 @@ test('Owner-S13 keeps the old published link readable and copyable without a sig
|
||||
});
|
||||
await expect.poll(() => loginStatusReadsAfterSignOut).toBeGreaterThan(0);
|
||||
await expect(fileWorkspace).toHaveCount(0);
|
||||
const oldLink = page.locator('code').filter({ hasText: publicUrl });
|
||||
// Same published-link block as the signed-in panel (chain icon + URL row), not a bespoke <code>.
|
||||
const oldLink = page.locator('.chrome-publish-plain .chrome-publish-url').filter({ hasText: publicUrl });
|
||||
await expect(oldLink).toHaveText(publicUrl);
|
||||
await expect(page.getByRole('switch', { name: /链接访问/ })).toBeDisabled();
|
||||
const loginToUpdate = page.getByRole('button', { name: /登录后更新/ });
|
||||
@@ -125,6 +126,7 @@ test('Owner-S13 keeps the old published link readable and copyable without a sig
|
||||
await page.evaluate(() => { delete document.body.dataset.copiedUrl; });
|
||||
await page.getByRole('button', { name: /复制链接|复制分享链接/ }).click();
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-copied-url')).toBe(publicUrl);
|
||||
await expect(page.getByRole('button', { name: '已复制', exact: true })).toBeEnabled();
|
||||
await capture(page, 'S13', { projectId, conversationId, publication: { slug, status: 'active', url: publicUrl },
|
||||
signedOutProject: 'unlisted after auth refresh', workspaceScopeReadsAfterSignOut: scopeReadsAfterSignOut,
|
||||
interaction: 'Old authenticated link copied before and after real sign-out; file source unmounted; login-to-update visible, auth recovery not exercised',
|
||||
|
||||
@@ -370,7 +370,13 @@ test('capture updated owner G1 portal and S12 deployment submenu',async({page})=
|
||||
await expect(submenu).toBeVisible();
|
||||
const bounds=await submenu.evaluate(el=>{const r=el.getBoundingClientRect();let node:Element|null=el;while(node&&node!==document.body)node=node.parentElement;const parent=el.parentElement;return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,viewport:{width:innerWidth,height:innerHeight},parentOverflow:getComputedStyle(parent!).overflow,portal:node===document.body,zIndex:getComputedStyle(el).zIndex}});
|
||||
expect(bounds.portal,'deployment submenu must be in a body portal').toBe(true);
|
||||
expect(bounds.right<=geometry.rect.x||bounds.x>=geometry.rect.x+geometry.rect.width,'submenu must sit outside the share panel').toBe(true);
|
||||
// Design S12: drops down under "···", right-aligned to the header tool group
|
||||
// (close button edge), layered over the share panel's own content.
|
||||
const trigger=await more.evaluate(el=>{const r=el.getBoundingClientRect();const tools=el.parentElement!.parentElement!.getBoundingClientRect();return {bottom:r.bottom,toolsRight:tools.right};});
|
||||
expect(bounds.y-trigger.bottom,'submenu must open just below the ··· trigger').toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.y-trigger.bottom,'submenu must open just below the ··· trigger').toBeLessThanOrEqual(10);
|
||||
expect(Math.abs(bounds.right-trigger.toolsRight),'submenu right edge aligns with the header tools').toBeLessThanOrEqual(1);
|
||||
expect(bounds.x>=geometry.rect.x&&bounds.right<=geometry.rect.right,'submenu must overlap the share panel, not fly out beside it').toBe(true);
|
||||
expect(await submenu.evaluate(el=>{const r=el.getBoundingClientRect();return el.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));}),'submenu must be hit-testable in front of the page').toBe(true);
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.right).toBeLessThanOrEqual(bounds.viewport.width);
|
||||
|
||||
Reference in New Issue
Block a user