feat: dashboard UI/UX overhaul from full review
Shared foundation: - New Modal component on native <dialog>.showModal() (focus trap, inert background, Escape, focus restore, scroll lock); all eight dialogs and both globalThis.confirm calls migrated to it - Design tokens for success/warning/danger surfaces; pill danger variant; .sr-only utility; toast layering above dialog backdrops; not-allowed cursor for validity-disabled buttons Conversations/attention: auto-scroll with jump-to-latest, per-conversation draft persistence, suggestion undo, 30s auto-refresh with refresh controls, unread emphasis and counts, blur-consistent share dialog, empty-accounts guidance, collapsed user lookup, relative timestamps, danger pills for critical items Media/library/outreach: click-blurred-to-reveal, lightbox with arrow navigation and captions, dirty-form preservation across refetches, upload validation with thumbnail preview, save feedback, result counts with load-more and clear-filters, ticking countdowns from a single clock Overview/performance/operations/settings: mutation errors inside the confirm dialog, live sending-window countdown, offline-aware loading states, per-panel loading/error with retry, 7/30/90-day range toggle, console scroll yielding with shown/retained counts, risky-save matching by subreddit name instead of index, useBlocker unsaved-changes guard, single-pass validation with focus-to-first-error, access panel JSON validation and input reset Tests: 806 -> 844 passing; both typechecks clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82be197b79
commit
e984bf20ac
46 files changed
+2357
-390
No files matched your search
@@ -16,7 +16,7 @@ export function useStatsQuery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useTimersQuery() {
|
export function useTimersQuery() {
|
||||||
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers });
|
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers, refetchInterval: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetRunningMutation() {
|
export function useSetRunningMutation() {
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { Modal } from './modal.js';
|
||||||
|
|
||||||
interface ConfirmationDialogProps {
|
interface ConfirmationDialogProps {
|
||||||
action: 'start' | 'stop';
|
action: 'start' | 'stop';
|
||||||
|
errorMessage?: string | null;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onCancel(): void;
|
onCancel(): void;
|
||||||
onConfirm(): void;
|
onConfirm(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmationDialog({ action, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
|
export function ConfirmationDialog({ action, errorMessage = null, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
|
||||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const isStarting = action === 'start';
|
const isStarting = action === 'start';
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cancelButtonRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-labelledby="confirmation-title" aria-modal="true" className="dialog-backdrop" role="dialog">
|
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="confirmation-title" onClose={onCancel}>
|
||||||
<div className="dialog-card">
|
|
||||||
<span className="eyebrow">Confirm action</span>
|
<span className="eyebrow">Confirm action</span>
|
||||||
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
|
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -25,15 +20,15 @@ export function ConfirmationDialog({ action, isPending, onCancel, onConfirm }: C
|
|||||||
? 'The bot will resume automated processing.'
|
? 'The bot will resume automated processing.'
|
||||||
: 'The bot will pause after its current operation.'}
|
: 'The bot will pause after its current operation.'}
|
||||||
</p>
|
</p>
|
||||||
|
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||||
<div className="dialog-actions">
|
<div className="dialog-actions">
|
||||||
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
|
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
|
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
|
||||||
{isPending ? 'Applying…' : `Confirm ${action}`}
|
{isPending ? 'Applying…' : `Confirm ${action}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef, useState, type PropsWithChildren, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
/** Class applied to the <dialog> element itself (it is the visible card). */
|
||||||
|
className?: string;
|
||||||
|
/** Blocks Escape/backdrop dismissal while a mutation is in flight. */
|
||||||
|
isDismissLocked?: boolean;
|
||||||
|
/** Close when the backdrop (the ::backdrop area of the dialog) is pressed. */
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
label?: string;
|
||||||
|
labelledBy?: string;
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared modal built on native <dialog>.showModal(): the browser provides
|
||||||
|
* focus trapping, inert background, and Escape handling. Render conditionally;
|
||||||
|
* mounting opens the dialog and unmounting restores focus to the opener.
|
||||||
|
*/
|
||||||
|
export function Modal({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
closeOnBackdrop = false,
|
||||||
|
isDismissLocked = false,
|
||||||
|
label,
|
||||||
|
labelledBy,
|
||||||
|
onClose,
|
||||||
|
}: PropsWithChildren<ModalProps>): ReactNode {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
const dismissLockedRef = useRef(isDismissLocked);
|
||||||
|
dismissLockedRef.current = isDismissLocked;
|
||||||
|
|
||||||
|
// Captured at first render, before autoFocus moves focus into the dialog.
|
||||||
|
const [previouslyFocused] = useState(() => document.activeElement);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (!dialog.open) {
|
||||||
|
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||||
|
else dialog.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (previouslyFocused instanceof HTMLElement && previouslyFocused.isConnected) {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previouslyFocused]);
|
||||||
|
|
||||||
|
function requestClose(): void {
|
||||||
|
if (!dismissLockedRef.current) onCloseRef.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
aria-label={label}
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
className={className ? `modal ${className}` : 'modal'}
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (closeOnBackdrop && event.target === event.currentTarget) requestClose();
|
||||||
|
}}
|
||||||
|
ref={dialogRef}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Modal } from './modal.js';
|
||||||
|
|
||||||
export interface OriginPost {
|
export interface OriginPost {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -36,27 +37,6 @@ function PostOriginMeta({ post }: { post: OriginPost }) {
|
|||||||
export function OriginPostCard({ post }: { post: OriginPost }) {
|
export function OriginPostCard({ post }: { post: OriginPost }) {
|
||||||
const [isImageRevealed, setIsImageRevealed] = useState(false);
|
const [isImageRevealed, setIsImageRevealed] = useState(false);
|
||||||
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false);
|
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false);
|
||||||
const previewTriggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const previewCloseRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
const closeImagePreview = useCallback(() => {
|
|
||||||
setIsImagePreviewOpen(false);
|
|
||||||
queueMicrotask(() => previewTriggerRef.current?.focus());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isImagePreviewOpen) return;
|
|
||||||
previewCloseRef.current?.focus();
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') closeImagePreview();
|
|
||||||
if (event.key === 'Tab') {
|
|
||||||
event.preventDefault();
|
|
||||||
previewCloseRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [closeImagePreview, isImagePreviewOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -64,11 +44,14 @@ export function OriginPostCard({ post }: { post: OriginPost }) {
|
|||||||
{post.imageUrl ? (
|
{post.imageUrl ? (
|
||||||
<div className="origin-post-image">
|
<div className="origin-post-image">
|
||||||
<button
|
<button
|
||||||
aria-label={`View source image full screen: ${post.title}`}
|
aria-label={isImageRevealed
|
||||||
|
? `View source image full screen: ${post.title}`
|
||||||
|
: `Reveal source image: ${post.title}`}
|
||||||
className="origin-post-image-preview-trigger"
|
className="origin-post-image-preview-trigger"
|
||||||
disabled={!isImageRevealed}
|
onClick={() => {
|
||||||
onClick={() => setIsImagePreviewOpen(true)}
|
if (isImageRevealed) setIsImagePreviewOpen(true);
|
||||||
ref={previewTriggerRef}
|
else setIsImageRevealed(true);
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<img alt={`Source post: ${post.title}`} className={isImageRevealed ? undefined : 'is-blurred'} loading="lazy" src={post.imageUrl} />
|
<img alt={`Source post: ${post.title}`} className={isImageRevealed ? undefined : 'is-blurred'} loading="lazy" src={post.imageUrl} />
|
||||||
@@ -86,22 +69,21 @@ export function OriginPostCard({ post }: { post: OriginPost }) {
|
|||||||
<a aria-label={post.title} className="origin-post-copy" href={post.url} rel="noreferrer" target="_blank">
|
<a aria-label={post.title} className="origin-post-copy" href={post.url} rel="noreferrer" target="_blank">
|
||||||
<PostOriginMeta post={post} />
|
<PostOriginMeta post={post} />
|
||||||
<strong>{post.title}</strong>
|
<strong>{post.title}</strong>
|
||||||
<span>Open original post</span>
|
<span>Open original post <span aria-hidden="true">↗</span></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{isImagePreviewOpen && post.imageUrl ? (
|
{isImagePreviewOpen && post.imageUrl ? (
|
||||||
<div
|
<Modal
|
||||||
aria-label={`Source image: ${post.title}`}
|
className="origin-image-preview"
|
||||||
aria-modal="true"
|
closeOnBackdrop
|
||||||
className="origin-image-preview-backdrop"
|
label={`Source image: ${post.title}`}
|
||||||
onMouseDown={(event) => { if (event.target === event.currentTarget) closeImagePreview(); }}
|
onClose={() => setIsImagePreviewOpen(false)}
|
||||||
role="dialog"
|
|
||||||
>
|
>
|
||||||
<div className="origin-image-preview-content">
|
<div className="origin-image-preview-content">
|
||||||
<img alt={`Source post: ${post.title}`} src={post.imageUrl} />
|
<img alt={`Source post: ${post.title}`} src={post.imageUrl} />
|
||||||
<button aria-label="Close source image preview" onClick={closeImagePreview} ref={previewCloseRef} type="button">×</button>
|
<button aria-label="Close source image preview" autoFocus onClick={() => setIsImagePreviewOpen(false)} type="button">×</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -109,50 +91,34 @@ export function OriginPostCard({ post }: { post: OriginPost }) {
|
|||||||
|
|
||||||
export function OriginPostButton({ children, post }: { children: ReactNode; post: OriginPost }) {
|
export function OriginPostButton({ children, post }: { children: ReactNode; post: OriginPost }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
closeRef.current?.focus();
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') setIsOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', closeOnEscape);
|
|
||||||
return () => document.removeEventListener('keydown', closeOnEscape);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
setIsOpen(false);
|
|
||||||
queueMicrotask(() => triggerRef.current?.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button className="origin-post-trigger" onClick={() => setIsOpen(true)} ref={triggerRef} type="button">
|
<button className="origin-post-trigger" onClick={() => setIsOpen(true)} type="button">
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div
|
<Modal
|
||||||
aria-labelledby="origin-post-dialog-title"
|
className="origin-post-dialog"
|
||||||
aria-modal="true"
|
closeOnBackdrop
|
||||||
className="origin-post-backdrop"
|
labelledBy="origin-post-dialog-title"
|
||||||
onMouseDown={(event) => { if (event.target === event.currentTarget) close(); }}
|
onClose={() => setIsOpen(false)}
|
||||||
role="dialog"
|
|
||||||
>
|
>
|
||||||
<article className="origin-post-dialog">
|
<article>
|
||||||
<div className="origin-post-dialog-heading">
|
<div className="origin-post-dialog-heading">
|
||||||
<span>Conversation origin</span>
|
<span>Conversation origin</span>
|
||||||
<button aria-label="Close source post" onClick={close} ref={closeRef} type="button">×</button>
|
<button aria-label="Close source post" autoFocus onClick={() => setIsOpen(false)} type="button">×</button>
|
||||||
</div>
|
</div>
|
||||||
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
|
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
|
||||||
<div className="origin-post-dialog-copy">
|
<div className="origin-post-dialog-copy">
|
||||||
<PostOriginMeta post={post} />
|
<PostOriginMeta post={post} />
|
||||||
<h2 id="origin-post-dialog-title">{post.title}</h2>
|
<h2 id="origin-post-dialog-title">{post.title}</h2>
|
||||||
<a className="button primary" href={post.url} rel="noreferrer" target="_blank">Open Reddit post</a>
|
<a className="button primary" href={post.url} rel="noreferrer" target="_blank">
|
||||||
|
Open Reddit post <span aria-hidden="true">↗</span><span className="sr-only">(opens in a new tab)</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,31 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { AttentionItem } from '../../../../../packages/api-contracts/src/index.js';
|
import type { AttentionItem } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
|
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
||||||
import { attentionApi } from './attention-api.js';
|
import { attentionApi } from './attention-api.js';
|
||||||
import './attention.css';
|
import './attention.css';
|
||||||
|
|
||||||
function formatTime(timestamp: number): string {
|
function relativeTime(timestamp: number, now: number) {
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
return (
|
||||||
|
<time dateTime={new Date(timestamp).toISOString()} title={formatExactTime(timestamp)}>
|
||||||
|
{formatShortRelativeTime(timestamp, now)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionHint(kind: AttentionItem['kind']): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'account-login-required':
|
||||||
|
return 'No linked conversation — check Settings.';
|
||||||
|
case 'runtime-failed':
|
||||||
|
case 'invitation-cooldown':
|
||||||
|
default:
|
||||||
|
return 'No linked conversation — check Operations.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemAction(item: AttentionItem) {
|
function itemAction(item: AttentionItem) {
|
||||||
if (!item.conversationId) return null;
|
if (!item.conversationId) return <span className="attention-item-hint">{actionHint(item.kind)}</span>;
|
||||||
return <Link className="button secondary" to={`/conversations/${encodeURIComponent(item.conversationId)}`}>Open conversation</Link>;
|
return <Link className="button secondary" to={`/conversations/${encodeURIComponent(item.conversationId)}`}>Open conversation</Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,14 +34,51 @@ export function AttentionPage() {
|
|||||||
const query = useQuery({ queryKey: ['attention'], queryFn: attentionApi.getAttention, refetchInterval: 30_000 });
|
const query = useQuery({ queryKey: ['attention'], queryFn: attentionApi.getAttention, refetchInterval: 30_000 });
|
||||||
if (query.isPending) return <div className="page-state" role="status">Loading attention inbox…</div>;
|
if (query.isPending) return <div className="page-state" role="status">Loading attention inbox…</div>;
|
||||||
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Attention inbox unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
|
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Attention inbox unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
|
||||||
|
const now = Date.now();
|
||||||
const critical = query.data.items.filter((item) => item.severity === 'critical').length;
|
const critical = query.data.items.filter((item) => item.severity === 'critical').length;
|
||||||
return (
|
return (
|
||||||
<div className="page attention-page">
|
<div className="page attention-page">
|
||||||
<header className="page-header"><div><span className="eyebrow">Operator queue</span><h1>Needs attention</h1><p>Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.</p></div><button className="button secondary" onClick={() => void query.refetch()} type="button">Refresh</button></header>
|
<header className="page-header">
|
||||||
<section aria-label="Attention totals" className="status-strip"><div><span className={`status-dot ${query.data.items.length === 0 ? 'running' : 'stopped'}`} aria-hidden="true" /><div><small>Open items</small><strong>{query.data.items.length}</strong></div></div><span className={critical > 0 ? 'pill warning' : 'pill success'}>{critical} critical</span><span className="status-note">Updated {formatTime(query.data.generatedAt)}</span></section>
|
<div>
|
||||||
|
<span className="eyebrow">Operator queue</span>
|
||||||
|
<h1>Needs attention</h1>
|
||||||
|
<p>Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.</p>
|
||||||
|
</div>
|
||||||
|
<div className="attention-refresh">
|
||||||
|
<button
|
||||||
|
aria-busy={query.isFetching || undefined}
|
||||||
|
className="button secondary"
|
||||||
|
disabled={query.isFetching}
|
||||||
|
onClick={() => void query.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{query.isFetching ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
<span className="attention-refresh-note">Auto-refreshes every 30s</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section aria-label="Attention totals" className="status-strip">
|
||||||
|
<div>
|
||||||
|
<span aria-hidden="true" className={`attention-status-dot ${query.data.items.length === 0 ? 'is-clear' : 'is-open'}`} />
|
||||||
|
<div><small>Open items</small><strong>{query.data.items.length}</strong></div>
|
||||||
|
</div>
|
||||||
|
<span className={critical > 0 ? 'pill danger' : 'pill success'}>{critical} critical</span>
|
||||||
|
<span className="status-note">Updated {relativeTime(query.data.generatedAt, now)}</span>
|
||||||
|
</section>
|
||||||
{query.data.items.length === 0 ? <div className="empty-state panel"><strong>Nothing needs attention.</strong><span>Automation and account health have no outstanding operator items.</span></div> : (
|
{query.data.items.length === 0 ? <div className="empty-state panel"><strong>Nothing needs attention.</strong><span>Automation and account health have no outstanding operator items.</span></div> : (
|
||||||
<ol aria-label="Attention items" className="attention-list">{query.data.items.map((item) => (
|
<ol aria-label="Attention items" className="attention-list">{query.data.items.map((item) => (
|
||||||
<li className={`attention-item is-${item.severity}`} key={item.id}><div className="attention-item-heading"><div><span className={`pill ${item.severity === 'critical' ? 'warning' : 'neutral'}`}>{item.kind.replaceAll('-', ' ')}</span><h2>{item.title}</h2></div><time dateTime={new Date(item.occurredAt).toISOString()}>{formatTime(item.occurredAt)}</time></div><p>{item.detail}</p>{item.owner ? <AccountOwnerBadge owner={item.owner} /> : null}{itemAction(item)}</li>
|
<li className={`attention-item is-${item.severity}`} key={item.id}>
|
||||||
|
<div className="attention-item-heading">
|
||||||
|
<div>
|
||||||
|
<span className={`pill ${item.severity === 'critical' ? 'danger' : 'warning'}`}>{item.kind.replaceAll('-', ' ')}</span>
|
||||||
|
<h2>{item.title}</h2>
|
||||||
|
</div>
|
||||||
|
{relativeTime(item.occurredAt, now)}
|
||||||
|
</div>
|
||||||
|
<p>{item.detail}</p>
|
||||||
|
{item.owner ? <AccountOwnerBadge owner={item.owner} /> : null}
|
||||||
|
{itemAction(item)}
|
||||||
|
</li>
|
||||||
))}</ol>
|
))}</ol>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,3 +6,9 @@
|
|||||||
.attention-item-heading time { color: var(--muted); font-size: .8rem; text-align: right; }
|
.attention-item-heading time { color: var(--muted); font-size: .8rem; text-align: right; }
|
||||||
.attention-item p { margin: 0; color: var(--muted); }
|
.attention-item p { margin: 0; color: var(--muted); }
|
||||||
.attention-item .button { justify-self: start; text-decoration: none; }
|
.attention-item .button { justify-self: start; text-decoration: none; }
|
||||||
|
.attention-item-hint { justify-self: start; color: var(--muted); font-size: .82rem; font-style: italic; }
|
||||||
|
.attention-refresh { display: grid; gap: .3rem; justify-items: end; }
|
||||||
|
.attention-refresh-note { color: var(--muted); font-size: .72rem; }
|
||||||
|
.attention-status-dot { width: .7rem; height: .7rem; border-radius: 50%; }
|
||||||
|
.attention-status-dot.is-clear { background: var(--success-text); box-shadow: 0 0 0 .35rem rgba(170, 240, 196, .12); }
|
||||||
|
.attention-status-dot.is-open { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* In-memory store of composer drafts keyed by conversation id, so switching
|
||||||
|
* conversations and back restores what the operator was typing.
|
||||||
|
*/
|
||||||
|
const drafts = new Map<string, string>();
|
||||||
|
|
||||||
|
export function getStoredComposerDraft(conversationId: string): string {
|
||||||
|
return drafts.get(conversationId) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeComposerDraft(conversationId: string, draft: string): void {
|
||||||
|
if (draft.length === 0) drafts.delete(conversationId);
|
||||||
|
else drafts.set(conversationId, draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper: clear all stored drafts between test cases. */
|
||||||
|
export function resetComposerDrafts(): void {
|
||||||
|
drafts.clear();
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { ConversationDetailResponse, MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js';
|
import type { ConversationDetailResponse, MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||||
import { OriginPostCard } from '../../components/origin-post.js';
|
import { OriginPostCard } from '../../components/origin-post.js';
|
||||||
@@ -13,29 +13,69 @@ import {
|
|||||||
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
|
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
|
||||||
import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js';
|
import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js';
|
||||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
|
import { formatExactTime } from './relative-time.js';
|
||||||
|
import { getStoredComposerDraft, storeComposerDraft } from './composer-drafts.js';
|
||||||
|
|
||||||
|
/** Reddit chat rejects messages longer than roughly this many characters. */
|
||||||
|
export const REDDIT_CHAT_MAX_MESSAGE_LENGTH = 10_000;
|
||||||
|
const CHARACTER_WARNING_MARGIN = 500;
|
||||||
|
const SUCCESS_NOTICE_DISMISS_MS = 5_000;
|
||||||
|
const AT_BOTTOM_THRESHOLD_PX = 48;
|
||||||
|
|
||||||
interface ConversationDetailProps {
|
interface ConversationDetailProps {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
onClose(): void;
|
onClose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessageTime(timestamp: number): string {
|
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mediaPath(localPath: string): string {
|
function mediaPath(localPath: string): string {
|
||||||
return `/media/${localPath.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
|
return `/media/${localPath.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auto-dismisses a success flag after a few seconds. */
|
||||||
|
function useAutoDismiss(isVisible: boolean, dismiss: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
const timer = window.setTimeout(dismiss, SUCCESS_NOTICE_DISMISS_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [isVisible, dismiss]);
|
||||||
|
}
|
||||||
|
|
||||||
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
|
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
|
||||||
const [revealedMedia, setRevealedMedia] = useState<Set<string>>(() => new Set());
|
const [revealedMedia, setRevealedMedia] = useState<Set<string>>(() => new Set());
|
||||||
|
const listRef = useRef<HTMLOListElement>(null);
|
||||||
|
const isAtBottomRef = useRef(true);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const messageCount = conversation.messages?.length ?? 0;
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
list.scrollTop = list.scrollHeight;
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stick to the latest message on mount and when new messages arrive,
|
||||||
|
// unless the operator has scrolled up to read history.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAtBottomRef.current) scrollToBottom();
|
||||||
|
}, [messageCount, scrollToBottom]);
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= AT_BOTTOM_THRESHOLD_PX;
|
||||||
|
isAtBottomRef.current = atBottom;
|
||||||
|
setIsAtBottom(atBottom);
|
||||||
|
}
|
||||||
|
|
||||||
if (!conversation.messages || conversation.messages.length === 0) {
|
if (!conversation.messages || conversation.messages.length === 0) {
|
||||||
return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
|
return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ol aria-label="Message history" className="conversation-messages">
|
<div className="conversation-messages-shell">
|
||||||
|
<ol aria-label="Message history" className="conversation-messages" onScroll={handleScroll} ref={listRef}>
|
||||||
{conversation.messages.map((message) => (
|
{conversation.messages.map((message) => (
|
||||||
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`}>
|
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`}>
|
||||||
<div>
|
<div>
|
||||||
@@ -43,7 +83,7 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
|
|||||||
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
|
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
|
||||||
: <RedditUsernameLink username={conversation.username} />}</strong>
|
: <RedditUsernameLink username={conversation.username} />}</strong>
|
||||||
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
|
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
|
||||||
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
|
<time dateTime={new Date(message.timestamp).toISOString()}>{formatExactTime(message.timestamp)}</time>
|
||||||
</div>
|
</div>
|
||||||
<p>{message.text}</p>
|
<p>{message.text}</p>
|
||||||
{message.media?.length ? (
|
{message.media?.length ? (
|
||||||
@@ -82,15 +122,23 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
{!isAtBottom ? (
|
||||||
|
<button className="conversation-jump-latest" onClick={scrollToBottom} type="button">
|
||||||
|
Jump to latest
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConversationComposer({ conversation, conversationId }: { conversation: ConversationDetailResponse; conversationId: string }) {
|
function ConversationComposer({ conversation, conversationId }: { conversation: ConversationDetailResponse; conversationId: string }) {
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState(() => getStoredComposerDraft(conversationId));
|
||||||
|
const [restorableDraft, setRestorableDraft] = useState<string | null>(null);
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
const [wasSent, setWasSent] = useState(false);
|
const [wasSent, setWasSent] = useState(false);
|
||||||
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
|
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
|
||||||
const [areSavedImagesRevealed, setAreSavedImagesRevealed] = useState(false);
|
const [areSavedImagesRevealed, setAreSavedImagesRevealed] = useState(false);
|
||||||
|
const [isMediaShareOpen, setIsMediaShareOpen] = useState(false);
|
||||||
const [isConfirmingMedia, setIsConfirmingMedia] = useState(false);
|
const [isConfirmingMedia, setIsConfirmingMedia] = useState(false);
|
||||||
const [wasMediaShared, setWasMediaShared] = useState(false);
|
const [wasMediaShared, setWasMediaShared] = useState(false);
|
||||||
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -98,12 +146,23 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
|||||||
const suggestionMutation = useSuggestReplyMutation(conversationId);
|
const suggestionMutation = useSuggestReplyMutation(conversationId);
|
||||||
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
|
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
|
||||||
const sendMutation = useSendReplyMutation(conversationId);
|
const sendMutation = useSendReplyMutation(conversationId);
|
||||||
const mediaOptionsQuery = useConversationMediaShareOptionsQuery(conversationId);
|
const mediaOptionsQuery = useConversationMediaShareOptionsQuery(conversationId, isMediaShareOpen);
|
||||||
const mediaShareMutation = useShareConversationMediaMutation(conversationId);
|
const mediaShareMutation = useShareConversationMediaMutation(conversationId);
|
||||||
const mediaOptions = mediaOptionsQuery.data?.assets ?? [];
|
const mediaOptions = mediaOptionsQuery.data?.assets ?? [];
|
||||||
const selectedAsset: MediaLibraryAsset | null = mediaOptions.find((asset) => asset.id === selectedAssetId) ?? null;
|
const selectedAsset: MediaLibraryAsset | null = mediaOptions.find((asset) => asset.id === selectedAssetId) ?? null;
|
||||||
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
|
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
|
||||||
const automationActive = conversation.automation.status === 'active' && conversation.automation.remainingTurns > 0;
|
const automationActive = conversation.automation.status === 'active' && conversation.automation.remainingTurns > 0;
|
||||||
|
const isNearCharacterLimit = draft.length >= REDDIT_CHAT_MAX_MESSAGE_LENGTH - CHARACTER_WARNING_MARGIN;
|
||||||
|
|
||||||
|
const updateDraft = useCallback((next: string) => {
|
||||||
|
setDraft(next);
|
||||||
|
storeComposerDraft(conversationId, next);
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
const dismissSent = useCallback(() => setWasSent(false), []);
|
||||||
|
const dismissMediaShared = useCallback(() => setWasMediaShared(false), []);
|
||||||
|
useAutoDismiss(wasSent, dismissSent);
|
||||||
|
useAutoDismiss(wasMediaShared, dismissMediaShared);
|
||||||
|
|
||||||
const closeConfirmation = useCallback(() => {
|
const closeConfirmation = useCallback(() => {
|
||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
@@ -115,21 +174,29 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
|||||||
queueMicrotask(() => mediaShareSummaryRef.current?.focus());
|
queueMicrotask(() => mediaShareSummaryRef.current?.focus());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function applySuggestion(suggestion: string) {
|
||||||
|
const priorDraft = draft;
|
||||||
|
updateDraft(suggestion);
|
||||||
|
if (priorDraft.trim().length > 0 && priorDraft !== suggestion) setRestorableDraft(priorDraft);
|
||||||
|
else setRestorableDraft(null);
|
||||||
|
}
|
||||||
|
|
||||||
function suggestReply() {
|
function suggestReply() {
|
||||||
setWasSent(false);
|
setWasSent(false);
|
||||||
suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
|
suggestionMutation.mutate(undefined, { onSuccess: applySuggestion });
|
||||||
}
|
}
|
||||||
|
|
||||||
function suggestMediaAsk() {
|
function suggestMediaAsk() {
|
||||||
setWasSent(false);
|
setWasSent(false);
|
||||||
mediaAskMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
|
mediaAskMutation.mutate(undefined, { onSuccess: applySuggestion });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendReply() {
|
function sendReply() {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
sendMutation.mutate(draft, {
|
sendMutation.mutate(draft, {
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
setDraft('');
|
updateDraft('');
|
||||||
|
setRestorableDraft(null);
|
||||||
setWasSent(true);
|
setWasSent(true);
|
||||||
},
|
},
|
||||||
onSettled: closeConfirmation,
|
onSettled: closeConfirmation,
|
||||||
@@ -168,24 +235,48 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
|||||||
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
|
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="conversation-reply"
|
id="conversation-reply"
|
||||||
|
maxLength={REDDIT_CHAT_MAX_MESSAGE_LENGTH}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setDraft(event.target.value);
|
updateDraft(event.target.value);
|
||||||
|
setRestorableDraft(null);
|
||||||
setWasSent(false);
|
setWasSent(false);
|
||||||
}}
|
}}
|
||||||
placeholder="Write or generate a reply"
|
placeholder="Write or generate a reply"
|
||||||
rows={5}
|
rows={5}
|
||||||
value={draft}
|
value={draft}
|
||||||
/>
|
/>
|
||||||
|
{restorableDraft !== null ? (
|
||||||
|
<div className="conversation-inline-notice" role="status">
|
||||||
|
<span>The suggestion replaced your draft.</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
updateDraft(restorableDraft);
|
||||||
|
setRestorableDraft(null);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{suggestionMutation.isError ? <div className="conversation-inline-error" role="alert">{suggestionMutation.error.message}</div> : null}
|
{suggestionMutation.isError ? <div className="conversation-inline-error" role="alert">{suggestionMutation.error.message}</div> : null}
|
||||||
{mediaAskMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaAskMutation.error.message}</div> : null}
|
{mediaAskMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaAskMutation.error.message}</div> : null}
|
||||||
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
|
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
|
||||||
{wasSent ? (
|
{wasSent ? (
|
||||||
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
|
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
|
||||||
) : null}
|
) : null}
|
||||||
<details className="conversation-media-share">
|
<details className="conversation-media-share" open={isMediaShareOpen}>
|
||||||
<summary ref={mediaShareSummaryRef}>Share a saved image</summary>
|
<summary
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsMediaShareOpen((current) => !current);
|
||||||
|
}}
|
||||||
|
ref={mediaShareSummaryRef}
|
||||||
|
>
|
||||||
|
Share a saved image
|
||||||
|
</summary>
|
||||||
<p>Only enabled, unused images matching the latest inbound request are available.</p>
|
<p>Only enabled, unused images matching the latest inbound request are available.</p>
|
||||||
{mediaOptionsQuery.isPending ? <span role="status">Loading saved images…</span> : null}
|
{mediaOptionsQuery.isLoading ? <span role="status">Loading saved images…</span> : null}
|
||||||
{mediaOptionsQuery.isError ? <div className="conversation-inline-error" role="alert">{mediaOptionsQuery.error.message}</div> : null}
|
{mediaOptionsQuery.isError ? <div className="conversation-inline-error" role="alert">{mediaOptionsQuery.error.message}</div> : null}
|
||||||
{mediaOptionsQuery.data?.reason ? <div className="conversation-media-unavailable">{mediaOptionsQuery.data.reason}</div> : null}
|
{mediaOptionsQuery.data?.reason ? <div className="conversation-media-unavailable">{mediaOptionsQuery.data.reason}</div> : null}
|
||||||
{mediaOptions.length > 0 ? (
|
{mediaOptions.length > 0 ? (
|
||||||
@@ -193,6 +284,9 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
|||||||
<button className="conversation-media-reveal" onClick={() => setAreSavedImagesRevealed((current) => !current)} type="button">
|
<button className="conversation-media-reveal" onClick={() => setAreSavedImagesRevealed((current) => !current)} type="button">
|
||||||
{areSavedImagesRevealed ? 'Blur saved images' : 'Reveal saved images'}
|
{areSavedImagesRevealed ? 'Blur saved images' : 'Reveal saved images'}
|
||||||
</button>
|
</button>
|
||||||
|
{!areSavedImagesRevealed ? (
|
||||||
|
<p className="conversation-media-hint">Reveal images to select one.</p>
|
||||||
|
) : null}
|
||||||
<div aria-label="Saved images available to share" className="conversation-media-options" role="radiogroup">
|
<div aria-label="Saved images available to share" className="conversation-media-options" role="radiogroup">
|
||||||
{mediaOptions.map((asset) => (
|
{mediaOptions.map((asset) => (
|
||||||
<label className={selectedAssetId === asset.id ? 'is-selected' : ''} key={asset.id}>
|
<label className={selectedAssetId === asset.id ? 'is-selected' : ''} key={asset.id}>
|
||||||
@@ -230,7 +324,9 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
|||||||
{wasMediaShared ? <div className="conversation-inline-success" role="status">Saved image shared with {redditUsernameLabel(conversation.username)}.</div> : null}
|
{wasMediaShared ? <div className="conversation-inline-success" role="status">Saved image shared with {redditUsernameLabel(conversation.username)}.</div> : null}
|
||||||
</details>
|
</details>
|
||||||
<div className="conversation-actions">
|
<div className="conversation-actions">
|
||||||
<span>{draft.length} characters</span>
|
<span className={isNearCharacterLimit ? 'conversation-char-counter is-warning' : 'conversation-char-counter'}>
|
||||||
|
{draft.length} / {REDDIT_CHAT_MAX_MESSAGE_LENGTH}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
aria-label="Review and send reply"
|
aria-label="Review and send reply"
|
||||||
className="conversation-primary"
|
className="conversation-primary"
|
||||||
@@ -252,6 +348,7 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
|||||||
) : null}
|
) : null}
|
||||||
{isConfirmingMedia && selectedAsset ? (
|
{isConfirmingMedia && selectedAsset ? (
|
||||||
<MediaShareConfirmationDialog
|
<MediaShareConfirmationDialog
|
||||||
|
areImagesRevealed={areSavedImagesRevealed}
|
||||||
asset={selectedAsset}
|
asset={selectedAsset}
|
||||||
isPending={mediaShareMutation.isPending}
|
isPending={mediaShareMutation.isPending}
|
||||||
onCancel={closeMediaConfirmation}
|
onCancel={closeMediaConfirmation}
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ export const conversationQueryKeys = {
|
|||||||
mediaShareOptions: (id: string) => ['conversations', 'media-share-options', id] as const,
|
mediaShareOptions: (id: string) => ['conversations', 'media-share-options', id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CONVERSATION_REFRESH_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
export function useConversationsQuery() {
|
export function useConversationsQuery() {
|
||||||
return useQuery({ queryKey: conversationQueryKeys.all, queryFn: conversationApi.list });
|
return useQuery({
|
||||||
|
queryKey: conversationQueryKeys.all,
|
||||||
|
queryFn: conversationApi.list,
|
||||||
|
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConversationDetailQuery(id: string | null) {
|
export function useConversationDetailQuery(id: string | null) {
|
||||||
@@ -17,6 +23,7 @@ export function useConversationDetailQuery(id: string | null) {
|
|||||||
queryKey: conversationQueryKeys.detail(id ?? ''),
|
queryKey: conversationQueryKeys.detail(id ?? ''),
|
||||||
queryFn: () => conversationApi.detail(id ?? ''),
|
queryFn: () => conversationApi.detail(id ?? ''),
|
||||||
enabled: id !== null,
|
enabled: id !== null,
|
||||||
|
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,10 +35,11 @@ export function useSuggestMediaAskMutation(id: string) {
|
|||||||
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
|
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConversationMediaShareOptionsQuery(id: string) {
|
export function useConversationMediaShareOptionsQuery(id: string, enabled: boolean) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: conversationQueryKeys.mediaShareOptions(id),
|
queryKey: conversationQueryKeys.mediaShareOptions(id),
|
||||||
queryFn: () => conversationApi.mediaShareOptions(id),
|
queryFn: () => conversationApi.mediaShareOptions(id),
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||||
import { ConversationDetail } from './conversation-detail.js';
|
import { ConversationDetail } from './conversation-detail.js';
|
||||||
@@ -12,6 +12,8 @@ import { formatExactTime, formatShortRelativeTime } from './relative-time.js';
|
|||||||
|
|
||||||
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
|
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
|
||||||
|
|
||||||
|
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
|
||||||
|
|
||||||
export interface ConversationFilters {
|
export interface ConversationFilters {
|
||||||
search: string;
|
search: string;
|
||||||
filter: ConversationFilter;
|
filter: ConversationFilter;
|
||||||
@@ -39,10 +41,14 @@ function ConversationListItem({ conversation, isSelected, now, onSelect }: {
|
|||||||
onSelect(): void;
|
onSelect(): void;
|
||||||
}) {
|
}) {
|
||||||
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
|
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
|
||||||
|
const relativeTime = formatShortRelativeTime(conversation.lastMessageTime, now);
|
||||||
|
const itemClasses = ['conversation-list-item'];
|
||||||
|
if (isSelected) itemClasses.push('is-selected');
|
||||||
|
if (conversation.unread) itemClasses.push('is-unread');
|
||||||
return (
|
return (
|
||||||
<li className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}>
|
<li className={itemClasses.join(' ')}>
|
||||||
<button
|
<button
|
||||||
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}${conversation.sellerDetected ? ', Seller' : ''}${conversation.hasReceivedImages ? ', Image received' : ''}`}
|
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}, ${relativeTime}${conversation.sellerDetected ? ', Seller' : ''}${conversation.hasReceivedImages ? ', Image received' : ''}`}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
className="conversation-list-target"
|
className="conversation-list-target"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
@@ -53,7 +59,7 @@ function ConversationListItem({ conversation, isSelected, now, onSelect }: {
|
|||||||
<span className="conversation-list-meta">
|
<span className="conversation-list-meta">
|
||||||
<small>{state}</small>
|
<small>{state}</small>
|
||||||
<time dateTime={new Date(conversation.lastMessageTime).toISOString()} title={formatExactTime(conversation.lastMessageTime)}>
|
<time dateTime={new Date(conversation.lastMessageTime).toISOString()} title={formatExactTime(conversation.lastMessageTime)}>
|
||||||
{formatShortRelativeTime(conversation.lastMessageTime, now)}
|
{relativeTime}
|
||||||
</time>
|
</time>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -71,6 +77,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
const [localFilters, setLocalFilters] = useState<ConversationFilters>({ search: '', filter: 'all', showSellers: false });
|
const [localFilters, setLocalFilters] = useState<ConversationFilters>({ search: '', filter: 'all', showSellers: false });
|
||||||
const [isStartingConversation, setIsStartingConversation] = useState(false);
|
const [isStartingConversation, setIsStartingConversation] = useState(false);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const detailPanelRef = useRef<HTMLElement>(null);
|
||||||
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
|
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
|
||||||
const activeFilters = filters ?? localFilters;
|
const activeFilters = filters ?? localFilters;
|
||||||
|
|
||||||
@@ -79,6 +86,15 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// On small viewports the detail panel sits below the list; bring it into
|
||||||
|
// view when a conversation is picked so the selection isn't invisible.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeConversationId) return;
|
||||||
|
if (typeof window.matchMedia !== 'function') return;
|
||||||
|
if (!window.matchMedia(SMALL_VIEWPORT_QUERY).matches) return;
|
||||||
|
detailPanelRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, [activeConversationId]);
|
||||||
|
|
||||||
function updateFilters(next: ConversationFilters) {
|
function updateFilters(next: ConversationFilters) {
|
||||||
if (filters === undefined) setLocalFilters(next);
|
if (filters === undefined) setLocalFilters(next);
|
||||||
onFiltersChange?.(next);
|
onFiltersChange?.(next);
|
||||||
@@ -98,6 +114,11 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
.sort((left, right) => right.lastMessageTime - left.lastMessageTime);
|
.sort((left, right) => right.lastMessageTime - left.lastMessageTime);
|
||||||
}, [activeFilters, conversationsQuery.data]);
|
}, [activeFilters, conversationsQuery.data]);
|
||||||
|
|
||||||
|
const unreadCount = useMemo(
|
||||||
|
() => (conversationsQuery.data ?? []).filter((conversation) => conversation.unread).length,
|
||||||
|
[conversationsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
function selectConversation(id: string | null) {
|
function selectConversation(id: string | null) {
|
||||||
if (selectedConversationId === undefined) setInternalSelectedId(id);
|
if (selectedConversationId === undefined) setInternalSelectedId(id);
|
||||||
onSelectedConversationChange?.(id);
|
onSelectedConversationChange?.(id);
|
||||||
@@ -111,9 +132,22 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
<h1>Conversations</h1>
|
<h1>Conversations</h1>
|
||||||
<p>Review replies, draft carefully, and send only after confirmation.</p>
|
<p>Review replies, draft carefully, and send only after confirmation.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="conversations-header-actions">
|
||||||
|
<div className="conversation-refresh">
|
||||||
|
<button
|
||||||
|
aria-busy={conversationsQuery.isFetching || undefined}
|
||||||
|
disabled={conversationsQuery.isFetching}
|
||||||
|
onClick={() => void conversationsQuery.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
<span className="conversation-refresh-note">Auto-refreshes every 30s</span>
|
||||||
|
</div>
|
||||||
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
||||||
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{isStartingConversation ? (
|
{isStartingConversation ? (
|
||||||
@@ -146,6 +180,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
<option value="awaiting-reply">Awaiting reply</option>
|
<option value="awaiting-reply">Awaiting reply</option>
|
||||||
<option value="received-images">Received images</option>
|
<option value="received-images">Received images</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span className="conversation-unread-count">{unreadCount} unread</span>
|
||||||
<label className="conversation-seller-toggle">
|
<label className="conversation-seller-toggle">
|
||||||
<input
|
<input
|
||||||
checked={activeFilters.showSellers}
|
checked={activeFilters.showSellers}
|
||||||
@@ -182,7 +217,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Selected conversation" className="conversation-detail-panel">
|
<section aria-label="Selected conversation" className="conversation-detail-panel" ref={detailPanelRef}>
|
||||||
{activeConversationId ? (
|
{activeConversationId ? (
|
||||||
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
|
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversations-header h1 { margin-bottom: .4rem; }
|
.conversations-header h1 { margin-bottom: .4rem; }
|
||||||
|
.conversations-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
|
||||||
|
.conversation-refresh { display: grid; gap: .25rem; justify-items: end; }
|
||||||
|
.conversation-refresh-note { color: var(--muted); font-size: .72rem; }
|
||||||
.conversations-header p { margin-bottom: 0; color: var(--muted); }
|
.conversations-header p { margin-bottom: 0; color: var(--muted); }
|
||||||
.conversation-eyebrow { display: block; margin-bottom: .4rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
.conversation-eyebrow { display: block; margin-bottom: .4rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
.conversation-primary { border-color: transparent !important; color: #06140b !important; background: var(--accent) !important; font-weight: 750; }
|
.conversation-primary { border-color: transparent !important; color: #06140b !important; background: var(--accent) !important; font-weight: 750; }
|
||||||
@@ -22,6 +25,12 @@
|
|||||||
.conversations-page button:disabled { cursor: wait; opacity: .58; }
|
.conversations-page button:disabled { cursor: wait; opacity: .58; }
|
||||||
.new-conversation-panel { padding: 1rem; margin-bottom: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
.new-conversation-panel { padding: 1rem; margin-bottom: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
||||||
.new-conversation-panel h2 { margin-bottom: 1rem; }
|
.new-conversation-panel h2 { margin-bottom: 1rem; }
|
||||||
|
.processed-user-lookup summary { cursor: pointer; }
|
||||||
|
.processed-user-lookup summary h2 { display: inline-block; margin: 0; vertical-align: middle; }
|
||||||
|
.processed-user-lookup[open] summary { margin-bottom: 1rem; }
|
||||||
|
.processed-user-lookup form + p, .processed-user-lookup ol { margin-top: .8rem; }
|
||||||
|
.conversation-form-hint { margin: 0; color: var(--muted); }
|
||||||
|
.conversation-form-hint a { color: var(--accent); }
|
||||||
.start-conversation-form { display: grid; gap: .8rem; }
|
.start-conversation-form { display: grid; gap: .8rem; }
|
||||||
.conversation-field { display: grid; gap: .35rem; }
|
.conversation-field { display: grid; gap: .35rem; }
|
||||||
.conversation-field label { color: var(--muted); font-size: .8rem; font-weight: 700; }
|
.conversation-field label { color: var(--muted); font-size: .8rem; font-weight: 700; }
|
||||||
@@ -32,12 +41,16 @@
|
|||||||
.conversation-workspace { display: grid; gap: .75rem; }
|
.conversation-workspace { display: grid; gap: .75rem; }
|
||||||
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
|
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
|
||||||
.conversation-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid var(--border); }
|
.conversation-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.conversation-unread-count { grid-column: 1 / -1; color: var(--accent); font-size: .75rem; font-weight: 800; }
|
||||||
.conversation-seller-toggle { grid-column: 1 / -1; display: inline-flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
|
.conversation-seller-toggle { grid-column: 1 / -1; display: inline-flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
|
||||||
.conversation-seller-toggle input { width: auto; accent-color: var(--accent); }
|
.conversation-seller-toggle input { width: auto; accent-color: var(--accent); }
|
||||||
.conversation-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
|
.conversation-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
|
||||||
.conversation-list-item { position: relative; display: grid; gap: .4rem; width: 100%; padding: .8rem; text-align: left; background: transparent; border: 1px solid transparent; border-radius: .6rem; }
|
.conversation-list-item { position: relative; display: grid; gap: .4rem; width: 100%; padding: .8rem; text-align: left; background: transparent; border: 1px solid transparent; border-radius: .6rem; }
|
||||||
.conversation-list-item:hover { background: var(--surface-raised); }
|
.conversation-list-item:hover { background: var(--surface-raised); }
|
||||||
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
|
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
|
||||||
|
.conversation-list-item.is-unread { padding-left: 1.4rem; }
|
||||||
|
.conversation-list-item.is-unread::before { content: ''; position: absolute; top: 1.2rem; left: .55rem; width: .45rem; height: .45rem; background: var(--accent); border-radius: 50%; }
|
||||||
|
.conversation-list-item.is-unread .conversation-list-heading strong { font-weight: 900; }
|
||||||
.conversation-list-target { position: absolute; inset: 0; z-index: 0; min-height: 0 !important; padding: 0 !important; background: transparent !important; border: 0 !important; }
|
.conversation-list-target { position: absolute; inset: 0; z-index: 0; min-height: 0 !important; padding: 0 !important; background: transparent !important; border: 0 !important; }
|
||||||
.conversation-list-target:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.conversation-list-target:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
.conversation-list-item > span { position: relative; z-index: 1; pointer-events: none; }
|
.conversation-list-item > span { position: relative; z-index: 1; pointer-events: none; }
|
||||||
@@ -57,6 +70,8 @@
|
|||||||
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
|
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
|
||||||
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
|
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
|
||||||
.triggering-post { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
|
.triggering-post { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
|
||||||
|
.conversation-messages-shell { position: relative; }
|
||||||
|
.conversation-jump-latest { position: absolute; right: 1rem; bottom: .75rem; z-index: 2; padding: .35rem .7rem !important; min-height: 2rem !important; color: #06140b !important; background: var(--accent) !important; border-color: transparent !important; border-radius: 999px !important; font-size: .78rem; font-weight: 800; box-shadow: 0 .4rem 1.2rem rgba(0, 0, 0, .35); }
|
||||||
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
|
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
|
||||||
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
|
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
|
||||||
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
|
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
|
||||||
@@ -75,7 +90,10 @@
|
|||||||
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||||
.conversation-section-heading h3 { margin: 0; }
|
.conversation-section-heading h3 { margin: 0; }
|
||||||
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
|
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
|
||||||
.conversation-inline-error, .conversation-inline-success { padding: .65rem .75rem; border-radius: .55rem; }
|
.conversation-char-counter.is-warning { color: var(--warning-text) !important; font-weight: 800; }
|
||||||
|
.conversation-inline-error, .conversation-inline-success, .conversation-inline-notice { padding: .65rem .75rem; border-radius: .55rem; }
|
||||||
|
.conversation-inline-notice { display: flex; align-items: center; justify-content: space-between; gap: .75rem; color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); }
|
||||||
|
.conversation-inline-notice button { min-height: 2rem !important; padding: .3rem .65rem !important; }
|
||||||
.conversation-inline-error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
.conversation-inline-error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
||||||
.conversation-inline-success { color: #b9f3ce; background: #163a26; border: 1px solid #28593d; }
|
.conversation-inline-success { color: #b9f3ce; background: #163a26; border: 1px solid #28593d; }
|
||||||
.conversation-media-share { display: grid; gap: .7rem; padding: .75rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
|
.conversation-media-share { display: grid; gap: .7rem; padding: .75rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
|
||||||
@@ -83,6 +101,7 @@
|
|||||||
.conversation-media-share summary { color: #e9f1ec; font-weight: 800; cursor: pointer; }
|
.conversation-media-share summary { color: #e9f1ec; font-weight: 800; cursor: pointer; }
|
||||||
.conversation-media-share p, .conversation-media-unavailable { margin: 0; color: var(--muted); font-size: .8rem; }
|
.conversation-media-share p, .conversation-media-unavailable { margin: 0; color: var(--muted); font-size: .8rem; }
|
||||||
.conversation-media-reveal { justify-self: start; }
|
.conversation-media-reveal { justify-self: start; }
|
||||||
|
.conversation-media-hint { color: var(--warning-text) !important; font-size: .78rem !important; font-weight: 700; }
|
||||||
.conversation-media-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; }
|
.conversation-media-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; }
|
||||||
.conversation-media-options label { display: grid; gap: .35rem; padding: .35rem; overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; cursor: pointer; }
|
.conversation-media-options label { display: grid; gap: .35rem; padding: .35rem; overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; cursor: pointer; }
|
||||||
.conversation-media-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
.conversation-media-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
@@ -91,9 +110,11 @@
|
|||||||
.conversation-media-options img { display: block; width: 100%; height: 8rem; object-fit: contain; background: #050907; border-radius: .35rem; transition: filter 160ms ease, transform 160ms ease; }
|
.conversation-media-options img { display: block; width: 100%; height: 8rem; object-fit: contain; background: #050907; border-radius: .35rem; transition: filter 160ms ease, transform 160ms ease; }
|
||||||
.conversation-media-options img.is-blurred { filter: blur(22px) brightness(.55); transform: scale(1.1); }
|
.conversation-media-options img.is-blurred { filter: blur(22px) brightness(.55); transform: scale(1.1); }
|
||||||
.conversation-media-options span { color: var(--muted); font-size: .7rem; text-transform: capitalize; }
|
.conversation-media-options span { color: var(--muted); font-size: .7rem; text-transform: capitalize; }
|
||||||
.conversation-media-confirmation img { display: block; width: 100%; max-height: 55vh; margin: 1rem 0; object-fit: contain; background: #050907; border-radius: .55rem; }
|
.conversation-media-confirmation img { display: block; width: 100%; max-height: 55vh; margin: 1rem 0; object-fit: contain; background: #050907; border-radius: .55rem; transition: filter 160ms ease; }
|
||||||
.conversation-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .74); }
|
.conversation-media-confirmation img.is-blurred { filter: blur(24px) brightness(.55); }
|
||||||
.conversation-dialog { width: min(30rem, 100%); padding: 1.25rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .9rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .45); }
|
.conversation-media-confirmation .conversation-message-image { overflow: hidden; border-radius: .55rem; }
|
||||||
|
dialog.conversation-dialog { width: min(30rem, calc(100% - 2rem)); }
|
||||||
|
dialog.conversation-media-confirmation { width: min(34rem, calc(100% - 2rem)); }
|
||||||
.conversation-dialog p { color: var(--muted); }
|
.conversation-dialog p { color: var(--muted); }
|
||||||
.conversation-dialog blockquote { max-height: 12rem; padding: .75rem; margin: 1rem 0; overflow: auto; background: #0b1410; border-left: 3px solid var(--accent); white-space: pre-wrap; }
|
.conversation-dialog blockquote { max-height: 12rem; padding: .75rem; margin: 1rem 0; overflow: auto; background: #0b1410; border-left: 3px solid var(--accent); white-space: pre-wrap; }
|
||||||
.conversation-dialog .conversation-actions { justify-content: flex-end; }
|
.conversation-dialog .conversation-actions { justify-content: flex-end; }
|
||||||
@@ -110,13 +131,13 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.75rem 0.875rem;
|
padding: 0.75rem 0.875rem;
|
||||||
border: 1px solid var(--border-color, #d7dce2);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.625rem;
|
||||||
background: rgba(54, 116, 181, 0.08);
|
background: rgba(54, 116, 181, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-automation-status span {
|
.conversation-automation-status span {
|
||||||
color: var(--muted-color, #606975);
|
color: var(--muted);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +171,7 @@
|
|||||||
border: 1px solid rgba(99, 102, 241, 0.35);
|
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(99, 102, 241, 0.12);
|
background: rgba(99, 102, 241, 0.12);
|
||||||
color: #5558c9;
|
color: #a5b4fc;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
|
|||||||
@@ -1,39 +1,56 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useState } from 'react';
|
||||||
import type { MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js';
|
import type { MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
interface MediaShareConfirmationDialogProps {
|
interface MediaShareConfirmationDialogProps {
|
||||||
asset: MediaLibraryAsset;
|
asset: MediaLibraryAsset;
|
||||||
|
/** Whether the operator already revealed the saved images in the composer. */
|
||||||
|
areImagesRevealed: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onCancel(): void;
|
onCancel(): void;
|
||||||
onConfirm(): void;
|
onConfirm(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaShareConfirmationDialog({ asset, isPending, onCancel, onConfirm }: MediaShareConfirmationDialogProps) {
|
export function MediaShareConfirmationDialog({ asset, areImagesRevealed, isPending, onCancel, onConfirm }: MediaShareConfirmationDialogProps) {
|
||||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
const [isRevealed, setIsRevealed] = useState(areImagesRevealed);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cancelButtonRef.current?.focus();
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape' && !isPending) onCancel();
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [isPending, onCancel]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-labelledby="media-share-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
|
<Modal
|
||||||
<div className="conversation-dialog conversation-media-confirmation">
|
className="dialog-card conversation-dialog conversation-media-confirmation"
|
||||||
|
isDismissLocked={isPending}
|
||||||
|
labelledBy="media-share-confirmation-title"
|
||||||
|
onClose={onCancel}
|
||||||
|
>
|
||||||
<span className="conversation-eyebrow">Confirm image</span>
|
<span className="conversation-eyebrow">Confirm image</span>
|
||||||
<h2 id="media-share-confirmation-title">Share this saved image?</h2>
|
<h2 id="media-share-confirmation-title">Share this saved image?</h2>
|
||||||
<p>This sends immediately through Reddit chat, then asks them to reciprocate without pressure.</p>
|
<p>This sends immediately through Reddit chat, then asks them to reciprocate without pressure.</p>
|
||||||
<img alt={`Saved image tagged ${asset.tags.join(', ')}`} src={asset.urlPath} />
|
<div className="conversation-message-image">
|
||||||
|
<img
|
||||||
|
alt={`Saved image tagged ${asset.tags.join(', ')}`}
|
||||||
|
className={isRevealed ? undefined : 'is-blurred'}
|
||||||
|
src={asset.urlPath}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-pressed={isRevealed}
|
||||||
|
className="conversation-image-reveal"
|
||||||
|
onClick={() => setIsRevealed((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isRevealed ? 'Blur image' : 'Reveal image'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="conversation-actions">
|
<div className="conversation-actions">
|
||||||
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
|
<button autoFocus disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||||
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
|
<button
|
||||||
|
aria-busy={isPending || undefined}
|
||||||
|
className="conversation-primary"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
{isPending ? 'Sharing…' : 'Confirm image share'}
|
{isPending ? 'Sharing…' : 'Confirm image share'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,24 +7,34 @@ export function ProcessedUserLookup() {
|
|||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const postsQuery = useProcessedPostsQuery(username);
|
const postsQuery = useProcessedPostsQuery(username);
|
||||||
|
// Only show results that belong to the current lookup; switching the
|
||||||
|
// username changes the query key, which clears stale data automatically.
|
||||||
|
const showResults = postsQuery.data !== undefined && !postsQuery.isFetching;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="processed-user-heading" className="new-conversation-panel">
|
<details className="new-conversation-panel processed-user-lookup">
|
||||||
|
<summary>
|
||||||
<h2 id="processed-user-heading">Processed user lookup</h2>
|
<h2 id="processed-user-heading">Processed user lookup</h2>
|
||||||
<form onSubmit={(event) => { event.preventDefault(); setUsername(input.trim().replace(/^u\//i, '')); }}>
|
</summary>
|
||||||
|
<form
|
||||||
|
className="start-conversation-form processed-user-form"
|
||||||
|
onSubmit={(event) => { event.preventDefault(); setUsername(input.trim().replace(/^u\//i, '')); }}
|
||||||
|
>
|
||||||
|
<div className="conversation-field">
|
||||||
<label htmlFor="processed-user">Lookup Reddit username</label>
|
<label htmlFor="processed-user">Lookup Reddit username</label>
|
||||||
<input id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
|
<input autoComplete="off" id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
|
||||||
<button disabled={input.trim().length === 0} type="submit">Look up user</button>
|
</div>
|
||||||
|
<button className="conversation-primary" disabled={input.trim().length === 0} type="submit">Look up user</button>
|
||||||
</form>
|
</form>
|
||||||
{postsQuery.isPending && username ? <p role="status">Looking up processed posts...</p> : null}
|
{postsQuery.isFetching && username ? <p role="status">Looking up processed posts...</p> : null}
|
||||||
{postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null}
|
{postsQuery.isError ? <p className="conversation-inline-error" role="alert">{postsQuery.error.message}</p> : null}
|
||||||
{postsQuery.data ? (
|
{showResults && postsQuery.data ? (
|
||||||
postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
|
postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
|
||||||
<ol aria-label={`Processed posts for ${redditUsernameLabel(username)}`}>
|
<ol aria-label={`Processed posts for ${redditUsernameLabel(username)}`}>
|
||||||
{postsQuery.data.map((post) => <li key={`${post.owner?.accountId ?? 'legacy'}:${post.id}`}><strong>{post.title}</strong><AccountOwnerBadge owner={post.owner} /><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
|
{postsQuery.data.map((post) => <li key={`${post.owner?.accountId ?? 'legacy'}:${post.id}`}><strong>{post.title}</strong><AccountOwnerBadge owner={post.owner} /><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
|
||||||
</ol>
|
</ol>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
interface SendConfirmationDialogProps {
|
interface SendConfirmationDialogProps {
|
||||||
draft: string;
|
draft: string;
|
||||||
@@ -8,31 +8,29 @@ interface SendConfirmationDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SendConfirmationDialog({ draft, isPending, onCancel, onConfirm }: SendConfirmationDialogProps) {
|
export function SendConfirmationDialog({ draft, isPending, onCancel, onConfirm }: SendConfirmationDialogProps) {
|
||||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cancelButtonRef.current?.focus();
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape' && !isPending) onCancel();
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [isPending, onCancel]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-labelledby="send-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
|
<Modal
|
||||||
<div className="conversation-dialog">
|
className="dialog-card conversation-dialog"
|
||||||
|
isDismissLocked={isPending}
|
||||||
|
labelledBy="send-confirmation-title"
|
||||||
|
onClose={onCancel}
|
||||||
|
>
|
||||||
<span className="conversation-eyebrow">Confirm message</span>
|
<span className="conversation-eyebrow">Confirm message</span>
|
||||||
<h2 id="send-confirmation-title">Send this reply?</h2>
|
<h2 id="send-confirmation-title">Send this reply?</h2>
|
||||||
<p>This sends immediately through Reddit chat.</p>
|
<p>This sends immediately through Reddit chat.</p>
|
||||||
<blockquote>{draft}</blockquote>
|
<blockquote>{draft}</blockquote>
|
||||||
<div className="conversation-actions">
|
<div className="conversation-actions">
|
||||||
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
|
<button autoFocus disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||||
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
|
<button
|
||||||
|
aria-busy={isPending || undefined}
|
||||||
|
className="conversation-primary"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
{isPending ? 'Sending…' : 'Confirm send'}
|
{isPending ? 'Sending…' : 'Confirm send'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import { useStartConversationMutation } from './conversation-queries.js';
|
import { useStartConversationMutation } from './conversation-queries.js';
|
||||||
import { useRedditAccountsQuery } from '../settings/settings-queries.js';
|
import { useRedditAccountsQuery } from '../settings/settings-queries.js';
|
||||||
import { ownedConversationId } from './conversation-identity.js';
|
import { ownedConversationId } from './conversation-identity.js';
|
||||||
|
|
||||||
|
const SUCCESS_NOTICE_DISMISS_MS = 5_000;
|
||||||
|
|
||||||
interface StartConversationFormProps {
|
interface StartConversationFormProps {
|
||||||
onStarted(conversationId: string): void;
|
onStarted(conversationId: string): void;
|
||||||
}
|
}
|
||||||
@@ -19,6 +22,26 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
|
|||||||
const mutation = useStartConversationMutation();
|
const mutation = useStartConversationMutation();
|
||||||
const canSubmit = accountId.length > 0 && username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
|
const canSubmit = accountId.length > 0 && username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (successUsername === null) return;
|
||||||
|
const timer = window.setTimeout(() => setSuccessUsername(null), SUCCESS_NOTICE_DISMISS_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [successUsername]);
|
||||||
|
|
||||||
|
if (accountsQuery.isPending) {
|
||||||
|
return <p className="conversation-form-hint" role="status">Loading Reddit accounts…</p>;
|
||||||
|
}
|
||||||
|
if (accountsQuery.isError) {
|
||||||
|
return <div className="conversation-inline-error" role="alert">{accountsQuery.error.message}</div>;
|
||||||
|
}
|
||||||
|
if (runningAccounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="conversation-form-hint">
|
||||||
|
No running accounts — start one in <Link to="/settings">Settings</Link>.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
|
|||||||
@@ -1,18 +1,83 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||||
import type { MediaLibraryAsset, MediaLibraryTag, MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
|
import type { MediaLibraryAsset, MediaLibraryTag, MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { MediaPreviewDialog } from '../media/media-preview-dialog.js';
|
||||||
import { useDeleteMediaLibraryMutation, useMediaLibraryQuery, useUpdateMediaLibraryMutation, useUploadMediaLibraryMutation } from './queries.js';
|
import { useDeleteMediaLibraryMutation, useMediaLibraryQuery, useUpdateMediaLibraryMutation, useUploadMediaLibraryMutation } from './queries.js';
|
||||||
import './media-library.css';
|
import './media-library.css';
|
||||||
|
|
||||||
const TAGS: readonly MediaLibraryTag[] = ['selfie', 'chest', 'topless', 'feet', 'penis'];
|
const TAGS: readonly MediaLibraryTag[] = ['selfie', 'chest', 'topless', 'feet', 'penis'];
|
||||||
|
const MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
|
||||||
|
const NOTICE_MS = 5_000;
|
||||||
|
|
||||||
export function MediaLibraryPage() {
|
export function MediaLibraryPage() {
|
||||||
const query = useMediaLibraryQuery();
|
const query = useMediaLibraryQuery();
|
||||||
const upload = useUploadMediaLibraryMutation();
|
const upload = useUploadMediaLibraryMutation();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
const [consent, setConsent] = useState(false);
|
const [consent, setConsent] = useState(false);
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file || typeof URL.createObjectURL !== 'function') {
|
||||||
|
setThumbnailUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setThumbnailUrl(url);
|
||||||
|
return () => {
|
||||||
|
if (typeof URL.revokeObjectURL === 'function') URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
const uploadSucceeded = upload.isSuccess;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uploadSucceeded) return;
|
||||||
|
const timer = setTimeout(() => upload.reset(), NOTICE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [uploadSucceeded]);
|
||||||
|
|
||||||
|
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const picked = event.target.files?.[0] ?? null;
|
||||||
|
if (!picked) {
|
||||||
|
setFile(null);
|
||||||
|
setFileError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!picked.type.startsWith('image/')) {
|
||||||
|
setFile(null);
|
||||||
|
setFileError('Only image files can be uploaded.');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (picked.size > MAX_UPLOAD_BYTES) {
|
||||||
|
setFile(null);
|
||||||
|
setFileError('Images must be 15 MB or smaller.');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFileError(null);
|
||||||
|
setFile(picked);
|
||||||
|
}
|
||||||
|
|
||||||
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading exchange library…</div>;
|
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading exchange library…</div>;
|
||||||
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Exchange library unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page-state error-state" role="alert">
|
||||||
|
<strong>Exchange library unavailable</strong>
|
||||||
|
<span>{query.error.message}</span>
|
||||||
|
<button
|
||||||
|
aria-busy={query.isFetching || undefined}
|
||||||
|
className="button secondary"
|
||||||
|
disabled={query.isFetching}
|
||||||
|
onClick={() => void query.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{query.isFetching ? 'Refreshing…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const assets = query.data ?? [];
|
const assets = query.data ?? [];
|
||||||
return (
|
return (
|
||||||
@@ -39,17 +104,26 @@ export function MediaLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<label className="library-file-field">
|
<label className="library-file-field">
|
||||||
<span>Choose image</span>
|
<span>Choose image</span>
|
||||||
<input accept="image/jpeg,image/png,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} type="file" />
|
<input accept="image/jpeg,image/png,image/webp" onChange={handleFileChange} ref={fileInputRef} type="file" />
|
||||||
|
{thumbnailUrl ? <img alt={`Preview of ${file?.name ?? 'selected image'}`} className="library-upload-thumbnail" src={thumbnailUrl} /> : null}
|
||||||
<small>{file ? file.name : 'No image selected'}</small>
|
<small>{file ? file.name : 'No image selected'}</small>
|
||||||
</label>
|
</label>
|
||||||
|
{fileError ? <div className="media-notice error" role="alert">{fileError}</div> : null}
|
||||||
<label className="library-consent-field">
|
<label className="library-consent-field">
|
||||||
<input checked={consent} onChange={(event) => setConsent(event.target.checked)} type="checkbox" />
|
<input checked={consent} onChange={(event) => setConsent(event.target.checked)} type="checkbox" />
|
||||||
<span>I confirm every depicted adult consented to this image being stored and sent by the bot.</span>
|
<span>I confirm every depicted adult consented to this image being stored and sent by the bot.</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
|
aria-busy={upload.isPending || undefined}
|
||||||
className="button primary"
|
className="button primary"
|
||||||
disabled={!file || !consent || upload.isPending}
|
disabled={!file || !consent || upload.isPending}
|
||||||
onClick={() => file && upload.mutate(file, { onSuccess: () => { setFile(null); setConsent(false); } })}
|
onClick={() => file && upload.mutate(file, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setFile(null);
|
||||||
|
setConsent(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
},
|
||||||
|
})}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{upload.isPending ? 'Classifying…' : 'Upload and classify'}
|
{upload.isPending ? 'Classifying…' : 'Upload and classify'}
|
||||||
@@ -78,36 +152,81 @@ function LibraryAssetCard({ asset }: { asset: MediaLibraryAsset }) {
|
|||||||
const deleteMutation = useDeleteMediaLibraryMutation();
|
const deleteMutation = useDeleteMediaLibraryMutation();
|
||||||
const [revealed, setRevealed] = useState(false);
|
const [revealed, setRevealed] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [showSaved, setShowSaved] = useState(false);
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
const [form, setForm] = useState<MediaLibraryUpdate>(() => ({
|
const [form, setForm] = useState<MediaLibraryUpdate>(() => ({
|
||||||
tags: asset.tags,
|
tags: asset.tags,
|
||||||
safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw',
|
safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw',
|
||||||
enabled: asset.enabled,
|
enabled: asset.enabled,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Refetches must not clobber a form the operator has touched.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isDirty) return;
|
||||||
setForm({ tags: asset.tags, safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw', enabled: asset.enabled });
|
setForm({ tags: asset.tags, safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw', enabled: asset.enabled });
|
||||||
}, [asset]);
|
}, [asset, isDirty]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSaved) return;
|
||||||
|
const timer = setTimeout(() => setShowSaved(false), NOTICE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [showSaved]);
|
||||||
|
|
||||||
|
// Armed delete confirmation reverts to the safe state if left alone.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
const timer = setTimeout(() => setConfirmDelete(false), NOTICE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [confirmDelete]);
|
||||||
|
|
||||||
|
function editForm(update: (current: MediaLibraryUpdate) => MediaLibraryUpdate) {
|
||||||
|
setIsDirty(true);
|
||||||
|
setForm(update);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleTag(tag: MediaLibraryTag) {
|
function toggleTag(tag: MediaLibraryTag) {
|
||||||
setForm((current) => ({
|
editForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
tags: current.tags.includes(tag) ? current.tags.filter((item) => item !== tag) : [...current.tags, tag],
|
tags: current.tags.includes(tag) ? current.tags.filter((item) => item !== tag) : [...current.tags, tag],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alt = `Library image tagged ${asset.tags.join(', ') || 'unclassified'}`;
|
||||||
const needsReview = asset.classificationStatus === 'failed' || asset.safety === 'unknown' || (asset.confidence ?? 0) < 0.7;
|
const needsReview = asset.classificationStatus === 'failed' || asset.safety === 'unknown' || (asset.confidence ?? 0) < 0.7;
|
||||||
return (
|
return (
|
||||||
<article className={asset.enabled ? 'library-card is-enabled' : 'library-card'}>
|
<article className={asset.enabled ? 'library-card is-enabled' : 'library-card'}>
|
||||||
<div className="library-image-frame">
|
<div className="library-image-frame">
|
||||||
<img alt={`Library image tagged ${asset.tags.join(', ') || 'unclassified'}`} className={revealed ? '' : 'is-blurred'} src={asset.urlPath} />
|
<button
|
||||||
<button className="library-reveal" onClick={() => setRevealed((value) => !value)} type="button">{revealed ? 'Blur' : 'Reveal'}</button>
|
aria-label={revealed ? `View ${asset.fileName} larger` : `Reveal library image ${asset.fileName}`}
|
||||||
|
className="library-image-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (revealed) setIsPreviewOpen(true);
|
||||||
|
else setRevealed(true);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img alt={alt} className={revealed ? '' : 'is-blurred'} src={asset.urlPath} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={revealed ? `Blur ${asset.fileName}` : `Reveal ${asset.fileName}`}
|
||||||
|
aria-pressed={revealed}
|
||||||
|
className="library-reveal"
|
||||||
|
onClick={() => setRevealed((value) => !value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{revealed ? 'Blur' : 'Reveal'}
|
||||||
|
</button>
|
||||||
<span className={`library-safety is-${asset.safety}`}>{asset.safety.toUpperCase()}</span>
|
<span className={`library-safety is-${asset.safety}`}>{asset.safety.toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="library-card-body">
|
<div className="library-card-body">
|
||||||
<div className="library-card-title">
|
<div className="library-card-title">
|
||||||
<strong>{asset.fileName}</strong>
|
<strong>{asset.fileName}</strong>
|
||||||
|
<span className="library-card-pills">
|
||||||
|
{isDirty ? <span className="pill warning">Unsaved changes</span> : null}
|
||||||
<span className={asset.enabled ? 'pill success' : 'pill neutral'}>{asset.enabled ? 'Enabled' : 'Disabled'}</span>
|
<span className={asset.enabled ? 'pill success' : 'pill neutral'}>{asset.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{needsReview ? <div className="library-review-note" role="status">Review the AI result before enabling this image.</div> : null}
|
|
||||||
{asset.description ? <p>{asset.description}</p> : <p>AI classification was unavailable. Add tags manually.</p>}
|
{asset.description ? <p>{asset.description}</p> : <p>AI classification was unavailable. Add tags manually.</p>}
|
||||||
<small>{asset.width} × {asset.height} · {formatBytes(asset.sizeBytes)}{asset.confidence === null ? '' : ` · ${Math.round(asset.confidence * 100)}% confidence`}</small>
|
<small>{asset.width} × {asset.height} · {formatBytes(asset.sizeBytes)}{asset.confidence === null ? '' : ` · ${Math.round(asset.confidence * 100)}% confidence`}</small>
|
||||||
<small>{asset.sendCount === 0 ? 'Never sent' : `Sent ${asset.sendCount} ${asset.sendCount === 1 ? 'time' : 'times'}${asset.lastSentAt ? ` · last ${formatDate(asset.lastSentAt)}` : ''}`}</small>
|
<small>{asset.sendCount === 0 ? 'Never sent' : `Sent ${asset.sendCount} ${asset.sendCount === 1 ? 'time' : 'times'}${asset.lastSentAt ? ` · last ${formatDate(asset.lastSentAt)}` : ''}`}</small>
|
||||||
@@ -118,16 +237,63 @@ function LibraryAssetCard({ asset }: { asset: MediaLibraryAsset }) {
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="library-controls">
|
<div className="library-controls">
|
||||||
<label>Safety<select onChange={(event) => setForm((current) => ({ ...current, safety: event.target.value as 'sfw' | 'nsfw' }))} value={form.safety}><option value="sfw">SFW</option><option value="nsfw">NSFW</option></select></label>
|
<label>Safety<select onChange={(event) => editForm((current) => ({ ...current, safety: event.target.value as 'sfw' | 'nsfw' }))} value={form.safety}><option value="sfw">SFW</option><option value="nsfw">NSFW</option></select></label>
|
||||||
<label className="library-enable"><input checked={form.enabled} onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))} type="checkbox" /><span>Available to bot</span></label>
|
<div className="library-enable-group">
|
||||||
|
<label className="library-enable"><input checked={form.enabled} onChange={(event) => editForm((current) => ({ ...current, enabled: event.target.checked }))} type="checkbox" /><span>Available to bot</span></label>
|
||||||
|
{needsReview ? <div className="library-review-note" role="status">Review the AI result before enabling this image.</div> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{updateMutation.isError ? <div className="media-notice error" role="alert">{updateMutation.error.message}</div> : null}
|
{updateMutation.isError ? <div className="media-notice error" role="alert">{updateMutation.error.message}</div> : null}
|
||||||
{deleteMutation.isError ? <div className="media-notice error" role="alert">{deleteMutation.error.message}</div> : null}
|
{deleteMutation.isError ? <div className="media-notice error" role="alert">{deleteMutation.error.message}</div> : null}
|
||||||
<div className="library-card-actions">
|
<div className="library-card-actions">
|
||||||
<button className="button primary" disabled={form.tags.length === 0 || updateMutation.isPending} onClick={() => updateMutation.mutate({ id: asset.id, update: form })} type="button">{updateMutation.isPending ? 'Saving…' : 'Save changes'}</button>
|
<button
|
||||||
{confirmDelete ? <><button className="button danger" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(asset.id)} type="button">Confirm delete</button><button className="button secondary" onClick={() => setConfirmDelete(false)} type="button">Cancel</button></> : <button className="button secondary" onClick={() => setConfirmDelete(true)} type="button">Delete</button>}
|
aria-busy={updateMutation.isPending || undefined}
|
||||||
|
className="button primary"
|
||||||
|
disabled={form.tags.length === 0 || updateMutation.isPending}
|
||||||
|
onClick={() => updateMutation.mutate({ id: asset.id, update: form }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsDirty(false);
|
||||||
|
setShowSaved(true);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
{showSaved ? <span className="library-saved-notice" role="status">Saved</span> : null}
|
||||||
|
{confirmDelete ? (
|
||||||
|
<>
|
||||||
|
<button className="button secondary" onClick={() => setConfirmDelete(false)} type="button">Cancel</button>
|
||||||
|
<button
|
||||||
|
aria-busy={deleteMutation.isPending || undefined}
|
||||||
|
className="button danger"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => deleteMutation.mutate(asset.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Confirm delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="button secondary" onClick={() => setConfirmDelete(true)} type="button">Delete</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{form.tags.length === 0 ? <small className="library-save-hint">Select at least one tag to save</small> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{isPreviewOpen ? (
|
||||||
|
<MediaPreviewDialog
|
||||||
|
entries={[{
|
||||||
|
id: asset.id,
|
||||||
|
src: asset.urlPath,
|
||||||
|
alt,
|
||||||
|
label: `Preview of ${asset.fileName}`,
|
||||||
|
captionTitle: asset.fileName,
|
||||||
|
captionSubtitle: `${asset.width} × ${asset.height}`,
|
||||||
|
}]}
|
||||||
|
index={0}
|
||||||
|
onClose={() => setIsPreviewOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,26 @@
|
|||||||
.library-card { overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--surface); }
|
.library-card { overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--surface); }
|
||||||
.library-card.is-enabled { border-color: #386d4d; box-shadow: inset 0 0 0 1px rgba(117, 214, 157, .08); }
|
.library-card.is-enabled { border-color: #386d4d; box-shadow: inset 0 0 0 1px rgba(117, 214, 157, .08); }
|
||||||
.library-image-frame { position: relative; min-height: 17rem; overflow: hidden; background: #050907; }
|
.library-image-frame { position: relative; min-height: 17rem; overflow: hidden; background: #050907; }
|
||||||
|
.library-image-button { display: block; width: 100%; padding: 0; border: 0; background: transparent; cursor: zoom-in; }
|
||||||
|
.library-image-button:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; }
|
||||||
.library-image-frame img { display: block; width: 100%; height: 17rem; object-fit: contain; transition: filter 180ms ease, transform 180ms ease; }
|
.library-image-frame img { display: block; width: 100%; height: 17rem; object-fit: contain; transition: filter 180ms ease, transform 180ms ease; }
|
||||||
.library-image-frame img.is-blurred { filter: blur(24px) brightness(.5); transform: scale(1.12); }
|
.library-image-frame img.is-blurred { filter: blur(24px) brightness(.5); transform: scale(1.12); }
|
||||||
.library-reveal { position: absolute; right: .75rem; bottom: .75rem; padding: .45rem .65rem; color: white; border: 1px solid rgba(255,255,255,.35); border-radius: .55rem; background: rgba(0,0,0,.78); font-weight: 750; }
|
.library-reveal { position: absolute; right: .75rem; bottom: .75rem; padding: .45rem .65rem; color: white; border: 1px solid rgba(255,255,255,.35); border-radius: .55rem; background: rgba(0,0,0,.78); font-weight: 750; }
|
||||||
.library-safety { position: absolute; top: .75rem; left: .75rem; padding: .3rem .5rem; border-radius: 999px; color: #08110d; background: var(--muted); font-size: .68rem; font-weight: 900; letter-spacing: .08em; }
|
.library-safety { position: absolute; top: .75rem; left: .75rem; padding: .3rem .5rem; border-radius: 999px; color: #08110d; background: var(--muted); font-size: .68rem; font-weight: 900; letter-spacing: .08em; }
|
||||||
.library-safety.is-sfw { background: var(--accent); }
|
.library-safety.is-sfw { background: var(--accent); }
|
||||||
.library-safety.is-nsfw { color: #21132d; background: var(--library-violet); }
|
.library-safety.is-nsfw { color: #21132d; background: var(--library-violet); }
|
||||||
|
.library-safety.is-unknown { color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); }
|
||||||
.library-card-body { display: grid; gap: .85rem; padding: 1rem; }
|
.library-card-body { display: grid; gap: .85rem; padding: 1rem; }
|
||||||
.library-card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
.library-card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||||
.library-card-title strong { overflow-wrap: anywhere; }
|
.library-card-title strong { overflow-wrap: anywhere; }
|
||||||
.library-card-body p { margin: 0; color: var(--muted); line-height: 1.45; }
|
.library-card-body p { margin: 0; color: var(--muted); line-height: 1.45; }
|
||||||
.library-card-body > small { color: var(--muted); }
|
.library-card-body > small { color: var(--muted); }
|
||||||
.library-review-note { padding: .6rem .7rem; color: #ffe1a0; border: 1px solid #59451e; border-radius: .55rem; background: #2a2111; font-size: .82rem; }
|
.library-review-note { padding: .6rem .7rem; color: var(--warning-text); border: 1px solid var(--warning-border); border-radius: .55rem; background: var(--warning-bg); font-size: .82rem; }
|
||||||
|
.library-card-pills { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-end; }
|
||||||
|
.library-enable-group { display: grid; gap: .5rem; }
|
||||||
|
.library-save-hint { color: var(--warning-text); }
|
||||||
|
.library-saved-notice { align-self: center; color: var(--success-text); font-weight: 750; }
|
||||||
|
.library-upload-thumbnail { display: block; max-width: 8rem; max-height: 8rem; border: 1px solid var(--border); border-radius: .5rem; object-fit: cover; }
|
||||||
.library-card fieldset { padding: 0; margin: 0; border: 0; }
|
.library-card fieldset { padding: 0; margin: 0; border: 0; }
|
||||||
.library-card legend { margin-bottom: .55rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
|
.library-card legend { margin-bottom: .55rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
|
||||||
.library-tag-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
|
.library-tag-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export function mediaType(item: MediaItem): 'image' | 'gif' {
|
|||||||
return item.fileName.toLowerCase().endsWith('.gif') ? 'gif' : 'image';
|
return item.fileName.toLowerCase().endsWith('.gif') ? 'gif' : 'image';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mediaAlt(item: MediaItem): string {
|
||||||
|
const kind = mediaType(item) === 'gif' ? 'GIF' : 'Image';
|
||||||
|
const date = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(item.timestamp);
|
||||||
|
return `${kind} from u/${item.sender}, ${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function filterMedia(items: MediaItem[], filters: MediaFilters, now: number): MediaItem[] {
|
export function filterMedia(items: MediaItem[], filters: MediaFilters, now: number): MediaItem[] {
|
||||||
const rangeMs = {
|
const rangeMs = {
|
||||||
all: null,
|
all: null,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
interface FullSyncDialogProps {
|
interface FullSyncDialogProps {
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
@@ -7,27 +7,24 @@ interface FullSyncDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FullSyncDialog({ isPending, onCancel, onConfirm }: FullSyncDialogProps) {
|
export function FullSyncDialog({ isPending, onCancel, onConfirm }: FullSyncDialogProps) {
|
||||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cancelButtonRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-labelledby="full-sync-title" aria-modal="true" className="dialog-backdrop" role="dialog">
|
<Modal
|
||||||
<div className="dialog-card">
|
className="dialog-card"
|
||||||
|
isDismissLocked={isPending}
|
||||||
|
labelledBy="full-sync-title"
|
||||||
|
onClose={onCancel}
|
||||||
|
>
|
||||||
<span className="eyebrow">Confirm expensive action</span>
|
<span className="eyebrow">Confirm expensive action</span>
|
||||||
<h2 id="full-sync-title">Run full media sync?</h2>
|
<h2 id="full-sync-title">Run full media sync?</h2>
|
||||||
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
|
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
|
||||||
<div className="dialog-actions">
|
<div className="dialog-actions">
|
||||||
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
|
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
<button aria-busy={isPending || undefined} className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||||
{isPending ? 'Syncing…' : 'Run full sync'}
|
{isPending ? 'Syncing…' : 'Run full sync'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import type { MediaItem } from '../../../../../packages/api-contracts/src/index.
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
|
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
|
||||||
import { mediaConversation, mediaType } from './filters.js';
|
import { mediaAlt, mediaConversation, mediaType } from './filters.js';
|
||||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
|
|
||||||
interface MediaCardProps {
|
interface MediaCardProps {
|
||||||
@@ -26,21 +26,23 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
|
|||||||
<article className="media-card">
|
<article className="media-card">
|
||||||
<div className="media-preview">
|
<div className="media-preview">
|
||||||
<button
|
<button
|
||||||
aria-label={`View media from ${item.sender} larger`}
|
aria-label={isRevealed ? `View media from ${item.sender} larger` : `Reveal media from ${item.sender}`}
|
||||||
className="media-preview-button"
|
className="media-preview-button"
|
||||||
disabled={!isRevealed}
|
onClick={() => {
|
||||||
onClick={onOpenPreview}
|
if (isRevealed) onOpenPreview();
|
||||||
|
else onToggleReveal();
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<img alt={`Media from ${item.sender}`} className={isRevealed ? 'media-image' : 'media-image is-blurred'} loading="lazy" src={item.urlPath} />
|
<img alt={mediaAlt(item)} className={isRevealed ? 'media-image' : 'media-image is-blurred'} loading="lazy" src={item.urlPath} />
|
||||||
</button>
|
</button>
|
||||||
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="media-card-body">
|
<div className="media-card-body">
|
||||||
<AccountOwnerBadge owner={item.owner} />
|
<AccountOwnerBadge owner={item.owner} />
|
||||||
<div>
|
<div>
|
||||||
<strong><RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
|
<strong><span className="media-link-prefix">From</span> <RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
|
||||||
<span>{item.chatUsername
|
<span><span className="media-link-prefix">Conversation</span> {item.chatUsername
|
||||||
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
|
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
|
||||||
: mediaConversation(item)}</span>
|
: mediaConversation(item)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,6 +56,7 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
|
aria-pressed={isRevealed}
|
||||||
className="button secondary media-reveal"
|
className="button secondary media-reveal"
|
||||||
onClick={onToggleReveal}
|
onClick={onToggleReveal}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { filterMedia, mediaConversation, type MediaFilters } from './filters.js';
|
import { filterMedia, mediaAlt, mediaConversation, type MediaFilters } from './filters.js';
|
||||||
import { FullSyncDialog } from './full-sync-dialog.js';
|
import { FullSyncDialog } from './full-sync-dialog.js';
|
||||||
import { MediaCard } from './media-card.js';
|
import { MediaCard } from './media-card.js';
|
||||||
import { MediaPreviewDialog } from './media-preview-dialog.js';
|
import { MediaPreviewDialog, type MediaPreviewEntry } from './media-preview-dialog.js';
|
||||||
import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js';
|
import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js';
|
||||||
import './media.css';
|
import './media.css';
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@ export const initialMediaFilters: MediaFilters = {
|
|||||||
dateRange: 'all',
|
dateRange: 'all',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 48;
|
||||||
|
const SUCCESS_NOTICE_MS = 5_000;
|
||||||
|
|
||||||
function errorMessage(error: Error | null): string {
|
function errorMessage(error: Error | null): string {
|
||||||
return error?.message ?? 'Something went wrong.';
|
return error?.message ?? 'Something went wrong.';
|
||||||
}
|
}
|
||||||
@@ -36,9 +39,11 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
const [isRevealAll, setIsRevealAll] = useState(false);
|
const [isRevealAll, setIsRevealAll] = useState(false);
|
||||||
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
|
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
|
||||||
const [previewItemId, setPreviewItemId] = useState<string | null>(null);
|
const [previewItemId, setPreviewItemId] = useState<string | null>(null);
|
||||||
|
const [visibleLimit, setVisibleLimit] = useState(PAGE_SIZE);
|
||||||
|
|
||||||
const items = mediaQuery.data ?? [];
|
const items = mediaQuery.data ?? [];
|
||||||
const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]);
|
const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]);
|
||||||
|
const displayedItems = visibleItems.slice(0, visibleLimit);
|
||||||
const conversations = useMemo(
|
const conversations = useMemo(
|
||||||
() => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)),
|
() => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)),
|
||||||
[items],
|
[items],
|
||||||
@@ -48,12 +53,37 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
[items],
|
[items],
|
||||||
);
|
);
|
||||||
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
|
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
|
||||||
const previewItem = items.find((item) => item.id === previewItemId) ?? null;
|
const isFiltered = filters.conversation !== 'all' || filters.sender !== 'all' || filters.type !== 'all' || filters.dateRange !== 'all';
|
||||||
|
const previewIndex = previewItemId === null ? -1 : visibleItems.findIndex((item) => item.id === previewItemId);
|
||||||
|
const previewEntries: MediaPreviewEntry[] = useMemo(
|
||||||
|
() => visibleItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
src: item.urlPath,
|
||||||
|
alt: mediaAlt(item),
|
||||||
|
label: `Media from ${item.sender}`,
|
||||||
|
captionTitle: `u/${item.sender}`,
|
||||||
|
captionSubtitle: formatLastSync(item.timestamp),
|
||||||
|
})),
|
||||||
|
[visibleItems],
|
||||||
|
);
|
||||||
|
|
||||||
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
|
const isSyncSuccess = syncMutation.isSuccess;
|
||||||
const next = { ...filters, [key]: value };
|
useEffect(() => {
|
||||||
|
if (!isSyncSuccess) return;
|
||||||
|
const timer = setTimeout(() => syncMutation.reset(), SUCCESS_NOTICE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// syncMutation is a fresh object every render; keying on isSuccess is intentional.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isSyncSuccess]);
|
||||||
|
|
||||||
|
function updateFilters(next: MediaFilters) {
|
||||||
if (controlledFilters === undefined) setLocalFilters(next);
|
if (controlledFilters === undefined) setLocalFilters(next);
|
||||||
onFiltersChange?.(next);
|
onFiltersChange?.(next);
|
||||||
|
setVisibleLimit(PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
|
||||||
|
updateFilters({ ...filters, [key]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleItem(itemId: string) {
|
function toggleItem(itemId: string) {
|
||||||
@@ -90,7 +120,15 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
<div className="page-state error-state" role="alert">
|
<div className="page-state error-state" role="alert">
|
||||||
<strong>Media unavailable</strong>
|
<strong>Media unavailable</strong>
|
||||||
<span>{errorMessage(mediaQuery.error)}</span>
|
<span>{errorMessage(mediaQuery.error)}</span>
|
||||||
<button className="button secondary" onClick={() => void mediaQuery.refetch()} type="button">Retry media</button>
|
<button
|
||||||
|
aria-busy={mediaQuery.isFetching || undefined}
|
||||||
|
className="button secondary"
|
||||||
|
disabled={mediaQuery.isFetching}
|
||||||
|
onClick={() => void mediaQuery.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{mediaQuery.isFetching ? 'Refreshing…' : 'Retry media'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -106,11 +144,9 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
|
aria-pressed={isRevealAll}
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
onClick={() => {
|
onClick={() => setIsRevealAll((current) => !current)}
|
||||||
setIsRevealAll((current) => !current);
|
|
||||||
setRevealedIds(new Set());
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{isRevealAll ? 'Blur all media' : 'Reveal all media'}
|
{isRevealAll ? 'Blur all media' : 'Reveal all media'}
|
||||||
@@ -127,7 +163,7 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
>
|
>
|
||||||
Full sync
|
Full sync
|
||||||
</button>
|
</button>
|
||||||
<button className="button primary" disabled={isSyncBusy} onClick={() => runSync(false)} type="button">
|
<button aria-busy={isSyncBusy || undefined} className="button primary" disabled={isSyncBusy} onClick={() => runSync(false)} type="button">
|
||||||
{isSyncBusy ? 'Syncing…' : 'Sync new media'}
|
{isSyncBusy ? 'Syncing…' : 'Sync new media'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +174,15 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
{statusQuery.isError ? (
|
{statusQuery.isError ? (
|
||||||
<div role="alert">
|
<div role="alert">
|
||||||
<span>{errorMessage(statusQuery.error)}</span>
|
<span>{errorMessage(statusQuery.error)}</span>
|
||||||
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry sync status</button>
|
<button
|
||||||
|
aria-busy={statusQuery.isFetching || undefined}
|
||||||
|
className="button secondary"
|
||||||
|
disabled={statusQuery.isFetching}
|
||||||
|
onClick={() => void statusQuery.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{statusQuery.isFetching ? 'Refreshing…' : 'Retry sync status'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{statusQuery.data ? (
|
{statusQuery.data ? (
|
||||||
@@ -188,13 +232,28 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
<option value="all">Any time</option><option value="24h">Past 24 hours</option><option value="7d">Past 7 days</option><option value="30d">Past 30 days</option>
|
<option value="all">Any time</option><option value="24h">Past 24 hours</option><option value="7d">Past 7 days</option><option value="30d">Past 30 days</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="media-filter-summary">
|
||||||
|
<span role="status">Showing {displayedItems.length} of {items.length} items</span>
|
||||||
|
{isFiltered ? (
|
||||||
|
<button className="button secondary" onClick={() => updateFilters(initialMediaFilters)} type="button">
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{visibleItems.length === 0 ? (
|
{visibleItems.length === 0 ? (
|
||||||
<section className="panel empty-state"><strong>No media matches these filters.</strong><span>Try broadening one or more filters.</span></section>
|
<section className="panel empty-state">
|
||||||
|
<strong>No media matches these filters.</strong>
|
||||||
|
<span>Try broadening one or more filters.</span>
|
||||||
|
<button className="button secondary" onClick={() => updateFilters(initialMediaFilters)} type="button">
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<section aria-label="Media gallery" className="media-grid">
|
<section aria-label="Media gallery" className="media-grid">
|
||||||
{visibleItems.map((item) => (
|
{displayedItems.map((item) => (
|
||||||
<MediaCard
|
<MediaCard
|
||||||
isRevealed={isRevealAll || revealedIds.has(item.id)}
|
isRevealed={isRevealAll || revealedIds.has(item.id)}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -204,6 +263,14 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
{visibleItems.length > displayedItems.length ? (
|
||||||
|
<div className="media-load-more">
|
||||||
|
<button className="button secondary" onClick={() => setVisibleLimit((current) => current + PAGE_SIZE)} type="button">
|
||||||
|
Load more ({visibleItems.length - displayedItems.length} remaining)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -215,7 +282,14 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
onConfirm={() => runSync(true)}
|
onConfirm={() => runSync(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{previewItem ? <MediaPreviewDialog item={previewItem} onClose={() => setPreviewItemId(null)} /> : null}
|
{previewIndex >= 0 ? (
|
||||||
|
<MediaPreviewDialog
|
||||||
|
entries={previewEntries}
|
||||||
|
index={previewIndex}
|
||||||
|
onClose={() => setPreviewItemId(null)}
|
||||||
|
onNavigate={(nextIndex) => setPreviewItemId(visibleItems[nextIndex]?.id ?? null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,119 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
|
export interface MediaPreviewEntry {
|
||||||
|
id: string;
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
/** Accessible name for the dialog. */
|
||||||
|
label: string;
|
||||||
|
/** Caption bar: sender (or file name) plus optional date line. */
|
||||||
|
captionTitle: string;
|
||||||
|
captionSubtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MediaPreviewDialogProps {
|
interface MediaPreviewDialogProps {
|
||||||
item: MediaItem;
|
entries: MediaPreviewEntry[];
|
||||||
|
index: number;
|
||||||
|
onNavigate?(index: number): void;
|
||||||
onClose(): void;
|
onClose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaPreviewDialog({ item, onClose }: MediaPreviewDialogProps) {
|
/**
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
* Full-screen lightbox for conversation and library media. When more than one
|
||||||
|
* entry is provided, previous/next buttons and ArrowLeft/ArrowRight navigate
|
||||||
|
* the visible set.
|
||||||
|
*/
|
||||||
|
export function MediaPreviewDialog({ entries, index, onNavigate, onClose }: MediaPreviewDialogProps) {
|
||||||
|
const entry = entries[index];
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
closeButtonRef.current?.focus();
|
setIsLoaded(false);
|
||||||
|
}, [entry?.src]);
|
||||||
|
|
||||||
|
const hasPrevious = index > 0;
|
||||||
|
const hasNext = index < entries.length - 1;
|
||||||
|
const canNavigate = entries.length > 1 && onNavigate !== undefined;
|
||||||
|
|
||||||
|
// Arrow keys navigate regardless of which control inside the modal holds
|
||||||
|
// focus (a nav button can become disabled, dropping focus).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canNavigate) return;
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') onClose();
|
if (event.key === 'ArrowLeft' && hasPrevious) {
|
||||||
|
event.preventDefault();
|
||||||
|
onNavigate?.(index - 1);
|
||||||
|
} else if (event.key === 'ArrowRight' && hasNext) {
|
||||||
|
event.preventDefault();
|
||||||
|
onNavigate?.(index + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onClose]);
|
}, [canNavigate, hasPrevious, hasNext, index, onNavigate]);
|
||||||
|
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
function goPrevious() {
|
||||||
|
if (hasPrevious) onNavigate?.(index - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
if (hasNext) onNavigate?.(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal className="media-preview-dialog" closeOnBackdrop label={entry.label} onClose={onClose}>
|
||||||
aria-label={`Media from ${item.sender}`}
|
|
||||||
aria-modal="true"
|
|
||||||
className="media-preview-dialog-backdrop"
|
|
||||||
onClick={(event) => {
|
|
||||||
if (event.target === event.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
role="dialog"
|
|
||||||
>
|
|
||||||
<div className="media-preview-dialog-content">
|
<div className="media-preview-dialog-content">
|
||||||
<img alt={`Media from ${item.sender}`} className="media-preview-dialog-image" src={item.urlPath} />
|
{!isLoaded ? (
|
||||||
<button aria-label="Close media preview" className="media-preview-dialog-close" onClick={onClose} ref={closeButtonRef} type="button">×</button>
|
<span className="media-preview-dialog-loading" role="status">
|
||||||
|
<span className="spinner" />
|
||||||
|
<span className="sr-only">Loading full-size media</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<img
|
||||||
|
alt={entry.alt}
|
||||||
|
className={isLoaded ? 'media-preview-dialog-image' : 'media-preview-dialog-image is-loading'}
|
||||||
|
onLoad={() => setIsLoaded(true)}
|
||||||
|
src={entry.src}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="Close media preview"
|
||||||
|
autoFocus
|
||||||
|
className="media-preview-dialog-close"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{canNavigate ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label="Previous media"
|
||||||
|
className="media-preview-dialog-nav is-previous"
|
||||||
|
disabled={!hasPrevious}
|
||||||
|
onClick={goPrevious}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Next media"
|
||||||
|
className="media-preview-dialog-nav is-next"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={goNext}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div className="media-preview-dialog-caption">
|
||||||
|
<strong>{entry.captionTitle}</strong>
|
||||||
|
{entry.captionSubtitle ? <span>{entry.captionSubtitle}</span> : null}
|
||||||
|
{canNavigate ? <span>{index + 1} of {entries.length}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
.media-sync-status > div[role="alert"] { width: 100%; justify-content: space-between; }
|
.media-sync-status > div[role="alert"] { width: 100%; justify-content: space-between; }
|
||||||
.media-sync-status .spinner { width: 1rem; height: 1rem; }
|
.media-sync-status .spinner { width: 1rem; height: 1rem; }
|
||||||
.media-notice { padding: .8rem 1rem; margin-bottom: 1rem; border-radius: .75rem; font-weight: 700; }
|
.media-notice { padding: .8rem 1rem; margin-bottom: 1rem; border-radius: .75rem; font-weight: 700; }
|
||||||
.media-notice.success { color: #aaf0c4; background: #163a26; border: 1px solid #28593d; }
|
.media-notice.success { color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); }
|
||||||
.media-notice.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
.media-notice.error { color: var(--danger-text); background: var(--danger-bg); border: 1px solid var(--danger-border); }
|
||||||
.media-filters { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-bottom: 1rem; }
|
.media-filters { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-bottom: 1rem; }
|
||||||
.media-filters label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
|
.media-filters label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
|
||||||
.media-filters select { width: 100%; min-height: 2.6rem; padding: .5rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
|
.media-filters select { width: 100%; min-height: 2.6rem; padding: .5rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
|
||||||
.media-filters select:focus-visible, .media-reveal:focus-visible, .media-conversation-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.media-filters select:focus-visible, .media-reveal:focus-visible, .media-conversation-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.media-filter-summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .6rem; grid-column: 1 / -1; color: var(--muted); font-size: .82rem; }
|
||||||
.media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
|
.media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
|
||||||
|
.media-load-more { display: flex; justify-content: center; margin-top: 1rem; }
|
||||||
.media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
.media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
||||||
.media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; }
|
.media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; }
|
||||||
.media-preview-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; cursor: zoom-in; }
|
.media-preview-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; cursor: zoom-in; }
|
||||||
@@ -20,17 +22,30 @@
|
|||||||
.media-card-body { display: grid; gap: .8rem; padding: .9rem; }
|
.media-card-body { display: grid; gap: .8rem; padding: .9rem; }
|
||||||
.media-card-body > div:first-child { display: flex; justify-content: space-between; gap: .75rem; }
|
.media-card-body > div:first-child { display: flex; justify-content: space-between; gap: .75rem; }
|
||||||
.media-card-body > div:first-child span { color: var(--muted); }
|
.media-card-body > div:first-child span { color: var(--muted); }
|
||||||
|
.media-link-prefix { color: var(--muted); font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
.media-card-body dl { display: grid; gap: .4rem; margin: 0; }
|
.media-card-body dl { display: grid; gap: .4rem; margin: 0; }
|
||||||
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
|
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
|
||||||
.media-card-body dt { color: var(--muted); }
|
.media-card-body dt { color: var(--muted); }
|
||||||
.media-card-body dd { margin: 0; text-align: right; }
|
.media-card-body dd { margin: 0; text-align: right; }
|
||||||
.media-conversation-link { display: flex; align-items: center; justify-content: center; width: 100%; text-decoration: none; }
|
.media-conversation-link { display: flex; align-items: center; justify-content: center; width: 100%; text-decoration: none; }
|
||||||
.media-reveal { width: 100%; }
|
.media-reveal { width: 100%; }
|
||||||
.media-preview-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, .88); }
|
|
||||||
|
/* Full-screen lightbox rendered through the shared <dialog> Modal. */
|
||||||
|
dialog.media-preview-dialog { width: 100vw; height: 100vh; max-width: 100vw; max-height: 100vh; padding: 1rem; border: 0; background: transparent; }
|
||||||
|
dialog.media-preview-dialog::backdrop { background: rgba(0, 0, 0, .9); }
|
||||||
.media-preview-dialog-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
.media-preview-dialog-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||||
.media-preview-dialog-image { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
|
.media-preview-dialog-image { display: block; width: auto; height: auto; max-width: 100%; max-height: calc(100% - 3.6rem); object-fit: contain; }
|
||||||
|
.media-preview-dialog-image.is-loading { opacity: 0; }
|
||||||
|
.media-preview-dialog-loading { position: absolute; display: grid; place-items: center; color: white; }
|
||||||
.media-preview-dialog-close { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; }
|
.media-preview-dialog-close { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; }
|
||||||
.media-preview-dialog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
.media-preview-dialog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||||
|
.media-preview-dialog-nav { position: absolute; top: 50%; width: 2.75rem; height: 2.75rem; padding: 0; transform: translateY(-50%); border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 1.8rem; line-height: 1; cursor: pointer; }
|
||||||
|
.media-preview-dialog-nav:disabled { opacity: .35; }
|
||||||
|
.media-preview-dialog-nav.is-previous { left: .5rem; }
|
||||||
|
.media-preview-dialog-nav.is-next { right: .5rem; }
|
||||||
|
.media-preview-dialog-nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||||
|
.media-preview-dialog-caption { position: absolute; bottom: 0; left: 50%; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: .35rem .8rem; max-width: 100%; padding: .55rem .9rem; transform: translateX(-50%); color: white; background: rgba(0, 0, 0, .72); border-radius: .6rem .6rem 0 0; font-size: .82rem; }
|
||||||
|
.media-preview-dialog-caption span { color: rgba(255, 255, 255, .75); }
|
||||||
|
|
||||||
@media (min-width: 40rem) {
|
@media (min-width: 40rem) {
|
||||||
.media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
import { useEffect, useReducer, useRef, useState } from 'react';
|
||||||
import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js';
|
import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { useDashboardRealtime } from '../../app/realtime-provider.js';
|
import { useDashboardRealtime } from '../../app/realtime-provider.js';
|
||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import { RedditUserText } from '../../components/reddit-user-text.js';
|
import { RedditUserText } from '../../components/reddit-user-text.js';
|
||||||
import { OriginPostButton } from '../../components/origin-post.js';
|
import { OriginPostButton } from '../../components/origin-post.js';
|
||||||
@@ -28,8 +29,8 @@ function ActivityView({ items }: { items: readonly ActivityItem[] }) {
|
|||||||
<p className="feature-empty">No recent operator activity.</p>
|
<p className="feature-empty">No recent operator activity.</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="operations-activity-list">
|
<ol className="operations-activity-list">
|
||||||
{items.map((item) => (
|
{items.map((item, index) => (
|
||||||
<li key={`${item.timestamp}-${item.message}`}>
|
<li key={`${item.timestamp}-${index}`}>
|
||||||
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
||||||
<AccountOwnerBadge owner={item.owner} />
|
<AccountOwnerBadge owner={item.owner} />
|
||||||
<div><strong>{item.postUrl && item.postTitle ? (
|
<div><strong>{item.postUrl && item.postTitle ? (
|
||||||
@@ -86,6 +87,40 @@ export interface OperationsPageProps {
|
|||||||
realtimeEnabled?: boolean;
|
realtimeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NOTICE_DISMISS_MS = 5_000;
|
||||||
|
|
||||||
|
const expensiveSyncCopy = {
|
||||||
|
full: {
|
||||||
|
title: 'Run a full cache sync?',
|
||||||
|
body: 'This rebuilds the entire chat cache and can take significantly longer than a delta sync.',
|
||||||
|
confirmLabel: 'Run full sync',
|
||||||
|
},
|
||||||
|
'fetch-all': {
|
||||||
|
title: 'Fetch every chat now?',
|
||||||
|
body: 'This is a legacy blocking operation that fetches every chat and may take a while.',
|
||||||
|
confirmLabel: 'Start fetching',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function ConfirmSyncDialog({ kind, onCancel, onConfirm }: {
|
||||||
|
kind: 'full' | 'fetch-all';
|
||||||
|
onCancel(): void;
|
||||||
|
onConfirm(): void;
|
||||||
|
}) {
|
||||||
|
const copy = expensiveSyncCopy[kind];
|
||||||
|
return (
|
||||||
|
<Modal className="dialog-card" labelledBy="confirm-sync-title" onClose={onCancel}>
|
||||||
|
<span className="eyebrow">Confirm action</span>
|
||||||
|
<h2 id="confirm-sync-title">{copy.title}</h2>
|
||||||
|
<p>{copy.body}</p>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button autoFocus className="button secondary" onClick={onCancel} type="button">Cancel</button>
|
||||||
|
<button className="button primary" onClick={onConfirm} type="button">{copy.confirmLabel}</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
|
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
|
||||||
const realtime = useDashboardRealtime();
|
const realtime = useDashboardRealtime();
|
||||||
const activityQuery = useActivityQuery();
|
const activityQuery = useActivityQuery();
|
||||||
@@ -94,12 +129,16 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
const syncMutation = useTriggerChatSyncMutation();
|
const syncMutation = useTriggerChatSyncMutation();
|
||||||
const fetchAllMutation = useFetchAllChatsMutation();
|
const fetchAllMutation = useFetchAllChatsMutation();
|
||||||
const [consoleClearNotice, setConsoleClearNotice] = useState(false);
|
const [consoleClearNotice, setConsoleClearNotice] = useState(false);
|
||||||
|
const [pendingSyncConfirm, setPendingSyncConfirm] = useState<'full' | 'fetch-all' | null>(null);
|
||||||
|
const [pauseAnnouncement, setPauseAnnouncement] = useState('');
|
||||||
|
const [isPinnedToBottom, setIsPinnedToBottom] = useState(true);
|
||||||
const [consoleState, dispatchConsole] = useReducer(
|
const [consoleState, dispatchConsole] = useReducer(
|
||||||
reduceLiveConsoleState,
|
reduceLiveConsoleState,
|
||||||
createLiveConsoleState(500),
|
createLiveConsoleState(500),
|
||||||
);
|
);
|
||||||
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
|
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
|
||||||
const consoleEnd = useRef<HTMLDivElement>(null);
|
const consoleEnd = useRef<HTMLDivElement>(null);
|
||||||
|
const consoleViewport = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logsQuery.data?.forEach((entry, index) => {
|
logsQuery.data?.forEach((entry, index) => {
|
||||||
@@ -136,22 +175,54 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
const visibleEntries = selectVisibleConsoleEntries(consoleState);
|
const visibleEntries = selectVisibleConsoleEntries(consoleState);
|
||||||
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
|
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
|
||||||
|
|
||||||
function confirmAndRun(kind: 'full' | 'fetch-all') {
|
function runExpensiveSync(kind: 'full' | 'fetch-all') {
|
||||||
const confirmed = globalThis.confirm(kind === 'full'
|
setPendingSyncConfirm(null);
|
||||||
? 'Run a full chat cache sync? This can take significantly longer than a delta sync.'
|
|
||||||
: 'Fetch every chat now? This is a legacy blocking operation and may take a while.');
|
|
||||||
if (!confirmed) return;
|
|
||||||
if (kind === 'full') syncMutation.mutate('full'); else fetchAllMutation.mutate();
|
if (kind === 'full') syncMutation.mutate('full'); else fetchAllMutation.mutate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetSyncMutation = syncMutation.reset;
|
||||||
|
const resetFetchAllMutation = fetchAllMutation.reset;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!syncMutation.isSuccess) return;
|
||||||
|
const timer = globalThis.setTimeout(() => resetSyncMutation(), NOTICE_DISMISS_MS);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [resetSyncMutation, syncMutation.isSuccess]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fetchAllMutation.isSuccess) return;
|
||||||
|
const timer = globalThis.setTimeout(() => resetFetchAllMutation(), NOTICE_DISMISS_MS);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [fetchAllMutation.isSuccess, resetFetchAllMutation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!consoleClearNotice) return;
|
||||||
|
const timer = globalThis.setTimeout(() => setConsoleClearNotice(false), NOTICE_DISMISS_MS);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [consoleClearNotice]);
|
||||||
|
|
||||||
|
function handleConsoleScroll() {
|
||||||
|
const viewport = consoleViewport.current;
|
||||||
|
if (!viewport) return;
|
||||||
|
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= 16;
|
||||||
|
setIsPinnedToBottom(atBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLatest() {
|
||||||
|
setIsPinnedToBottom(true);
|
||||||
|
consoleEnd.current?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
consoleState.isAutoScrollEnabled &&
|
consoleState.isAutoScrollEnabled &&
|
||||||
|
isPinnedToBottom &&
|
||||||
!consoleState.isPaused &&
|
!consoleState.isPaused &&
|
||||||
typeof consoleEnd.current?.scrollIntoView === 'function'
|
typeof consoleEnd.current?.scrollIntoView === 'function'
|
||||||
) {
|
) {
|
||||||
consoleEnd.current.scrollIntoView({ block: 'nearest' });
|
consoleEnd.current.scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused]);
|
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused, isPinnedToBottom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page operations-page">
|
<div className="page operations-page">
|
||||||
@@ -167,7 +238,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
</section>
|
</section>
|
||||||
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
|
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
|
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
|
||||||
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => confirmAndRun('full')} type="button">Full cache sync</button><button className="button secondary" disabled={syncBusy} onClick={() => confirmAndRun('fetch-all')} type="button">Fetch all chats</button></div></>}
|
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('full')} type="button">Full cache sync</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('fetch-all')} type="button">Fetch all chats</button></div></>}
|
||||||
{syncMutation.isSuccess ? <p role="status">{syncMutation.data.kind === 'delta' ? 'Delta' : 'Full'} sync started. Progress is shown from server status.</p> : null}
|
{syncMutation.isSuccess ? <p role="status">{syncMutation.data.kind === 'delta' ? 'Delta' : 'Full'} sync started. Progress is shown from server status.</p> : null}
|
||||||
{fetchAllMutation.isSuccess ? <p role="status">Fetched {fetchAllMutation.data.count} chats.</p> : null}
|
{fetchAllMutation.isSuccess ? <p role="status">Fetched {fetchAllMutation.data.count} chats.</p> : null}
|
||||||
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
|
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
|
||||||
@@ -175,16 +246,28 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
|
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{consoleState.entries.length} retained</span></div>
|
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{visibleEntries.length === consoleState.entries.length
|
||||||
|
? `${consoleState.entries.length} retained`
|
||||||
|
: `${visibleEntries.length} shown · ${consoleState.entries.length} retained`}</span></div>
|
||||||
<div className="console-controls">
|
<div className="console-controls">
|
||||||
<button aria-pressed={consoleState.isPaused} className="button secondary" onClick={() => dispatchConsole({ type: 'set-paused', paused: !consoleState.isPaused })} type="button">{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
|
<button
|
||||||
|
aria-pressed={consoleState.isPaused}
|
||||||
|
className="button secondary"
|
||||||
|
onClick={() => {
|
||||||
|
const paused = !consoleState.isPaused;
|
||||||
|
dispatchConsole({ type: 'set-paused', paused });
|
||||||
|
setPauseAnnouncement(paused ? 'Console paused.' : 'Console resumed.');
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
|
||||||
<button className="button secondary" onClick={() => { dispatchConsole({ type: 'clear' }); setConsoleClearNotice(true); }} type="button">Clear local console</button>
|
<button className="button secondary" onClick={() => { dispatchConsole({ type: 'clear' }); setConsoleClearNotice(true); }} type="button">Clear local console</button>
|
||||||
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
|
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
|
||||||
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
|
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
|
||||||
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
|
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
|
||||||
</div>
|
</div>
|
||||||
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
|
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
|
||||||
<div aria-live="polite" className="live-console" role="log">
|
<p aria-live="polite" className="sr-only" role="status">{pauseAnnouncement}</p>
|
||||||
|
<div className="live-console" onScroll={handleConsoleScroll} ref={consoleViewport} role="log">
|
||||||
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => {
|
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => {
|
||||||
const owner = accountOwnerFromLogData(entry.data);
|
const owner = accountOwnerFromLogData(entry.data);
|
||||||
return (
|
return (
|
||||||
@@ -198,7 +281,17 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
})}
|
})}
|
||||||
<div ref={consoleEnd} />
|
<div ref={consoleEnd} />
|
||||||
</div>
|
</div>
|
||||||
|
{!isPinnedToBottom ? (
|
||||||
|
<button className="button secondary console-jump" onClick={jumpToLatest} type="button">Jump to latest</button>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
{pendingSyncConfirm ? (
|
||||||
|
<ConfirmSyncDialog
|
||||||
|
kind={pendingSyncConfirm}
|
||||||
|
onCancel={() => setPendingSyncConfirm(null)}
|
||||||
|
onConfirm={() => runExpensiveSync(pendingSyncConfirm)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.operations-page { display: grid; gap: .8rem; }
|
.operations-page { display: grid; gap: .8rem; }
|
||||||
.connection-banner { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; color: #aaf0c4; background: #163a26; border: 1px solid #28593d; border-radius: .75rem; }
|
.connection-banner { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); border-radius: .75rem; }
|
||||||
.connection-banner.stale { color: #ffe1a0; background: #2a2111; border-color: #59451e; }
|
.connection-banner.stale { color: var(--warning-text); background: var(--warning-bg); border-color: var(--warning-border); }
|
||||||
.operations-summary-grid { display: grid; gap: .8rem; }
|
.operations-summary-grid { display: grid; gap: .8rem; }
|
||||||
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
|
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
|
||||||
.operations-activity-list li { display: flex; gap: .75rem; }
|
.operations-activity-list li { display: flex; gap: .75rem; }
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
.console-entry.warn strong { color: var(--warning); }
|
.console-entry.warn strong { color: var(--warning); }
|
||||||
.console-entry.error strong { color: var(--danger); }
|
.console-entry.error strong { color: var(--danger); }
|
||||||
.live-console > p { color: var(--muted); text-align: center; }
|
.live-console > p { color: var(--muted); text-align: center; }
|
||||||
|
.console-panel { position: relative; }
|
||||||
|
.console-jump { position: absolute; right: 1.6rem; bottom: 1.6rem; box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .4); }
|
||||||
@media (min-width: 56rem) {
|
@media (min-width: 56rem) {
|
||||||
.operations-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.operations-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.console-controls { grid-template-columns: auto minmax(8rem, .45fr) minmax(14rem, 1fr) auto; align-items: end; }
|
.console-controls { grid-template-columns: auto minmax(8rem, .45fr) minmax(14rem, 1fr) auto; align-items: end; }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
|
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
@@ -18,8 +19,14 @@ const STAGES: ReadonlyArray<{
|
|||||||
{ status: 'retryable', label: 'Retrying', description: 'Cooling down' },
|
{ status: 'retryable', label: 'Retrying', description: 'Cooling down' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const KNOWN_STATUSES = new Set<string>(STAGES.map((stage) => stage.status));
|
||||||
|
|
||||||
function statusLabel(status: OutreachCandidateStatus): string {
|
function statusLabel(status: OutreachCandidateStatus): string {
|
||||||
return STAGES.find((stage) => stage.status === status)?.label ?? status;
|
const stage = STAGES.find((candidate) => candidate.status === status);
|
||||||
|
if (stage) return stage.label;
|
||||||
|
// Unknown statuses fall back to a readable neutral label instead of the raw enum.
|
||||||
|
const readable = String(status).replaceAll(/[-_]/g, ' ');
|
||||||
|
return readable.charAt(0).toUpperCase() + readable.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWaitTime(timestamp: number, now: number): string {
|
function formatWaitTime(timestamp: number, now: number): string {
|
||||||
@@ -32,13 +39,14 @@ function formatWaitTime(timestamp: number, now: number): string {
|
|||||||
|
|
||||||
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
|
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
|
||||||
const waitingUntil = candidate.availableAt > now;
|
const waitingUntil = candidate.availableAt > now;
|
||||||
|
const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
|
||||||
return (
|
return (
|
||||||
<li className={`outreach-candidate is-${candidate.status}`}>
|
<li className={`outreach-candidate ${isKnownStatus ? `is-${candidate.status}` : 'is-unknown'}`}>
|
||||||
<div className="candidate-status-rail" aria-hidden="true"><span /></div>
|
<div className="candidate-status-rail" aria-hidden="true"><span /></div>
|
||||||
<article>
|
<article>
|
||||||
<div className="candidate-heading">
|
<div className="candidate-heading">
|
||||||
<div className="candidate-labels">
|
<div className="candidate-labels">
|
||||||
<span className={`candidate-status status-${candidate.status}`}>{statusLabel(candidate.status)}</span>
|
<span className={`candidate-status ${isKnownStatus ? `status-${candidate.status}` : 'status-neutral'}`}>{statusLabel(candidate.status)}</span>
|
||||||
<span>r/{candidate.subreddit}</span>
|
<span>r/{candidate.subreddit}</span>
|
||||||
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
|
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +54,10 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
|
|||||||
queued {formatShortRelativeTime(candidate.discoveredAt, now)}
|
queued {formatShortRelativeTime(candidate.discoveredAt, now)}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">{candidate.title}</a>
|
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">
|
||||||
|
{candidate.title} <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
<span className="sr-only">(opens Reddit in a new tab)</span>
|
||||||
<div className="candidate-meta">
|
<div className="candidate-meta">
|
||||||
<RedditUsernameLink username={candidate.author} />
|
<RedditUsernameLink username={candidate.author} />
|
||||||
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
|
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
|
||||||
@@ -66,16 +77,49 @@ export function OutreachPage() {
|
|||||||
queryFn: outreachApi.getFunnel,
|
queryFn: outreachApi.getFunnel,
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
if (query.isPending) return <div className="page-state" role="status">Loading outreach funnel…</div>;
|
|
||||||
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Outreach funnel unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
|
// Single client-side clock for every displayed offset: retry countdowns tick
|
||||||
|
// every second while visible, everything else every 10 seconds.
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const hasVisibleCountdown = (query.data?.candidates ?? []).some((candidate) => candidate.availableAt > now);
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setNow(Date.now()), hasVisibleCountdown ? 1_000 : 10_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [hasVisibleCountdown]);
|
||||||
|
|
||||||
|
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading outreach funnel…</div>;
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page-state error-state" role="alert">
|
||||||
|
<strong>Outreach funnel unavailable</strong>
|
||||||
|
<span>{query.error.message}</span>
|
||||||
|
<button
|
||||||
|
aria-busy={query.isFetching || undefined}
|
||||||
|
className="button secondary"
|
||||||
|
disabled={query.isFetching}
|
||||||
|
onClick={() => void query.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{query.isFetching ? 'Refreshing…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = query;
|
const { data } = query;
|
||||||
const now = data.generatedAt;
|
|
||||||
return (
|
return (
|
||||||
<div className="page outreach-page">
|
<div className="page outreach-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<div><span className="eyebrow">Live candidate queue</span><h1>Outreach funnel</h1><p>Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.</p></div>
|
<div><span className="eyebrow">Live candidate queue</span><h1>Outreach funnel</h1><p>Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.</p></div>
|
||||||
<button className="button secondary" disabled={query.isFetching} onClick={() => void query.refetch()} type="button">{query.isFetching ? 'Refreshing…' : 'Refresh'}</button>
|
<button
|
||||||
|
aria-busy={query.isFetching || undefined}
|
||||||
|
className="button secondary"
|
||||||
|
disabled={query.isFetching}
|
||||||
|
onClick={() => void query.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{query.isFetching ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
|
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
|
||||||
@@ -96,7 +140,13 @@ export function OutreachPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="active-candidates-heading" className="candidate-section">
|
<section aria-labelledby="active-candidates-heading" className="candidate-section">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div><span className="candidate-updated">{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}Updated {formatShortRelativeTime(data.generatedAt, Date.now())}</span></div>
|
<div className="panel-heading">
|
||||||
|
<div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div>
|
||||||
|
<span className="candidate-updated">
|
||||||
|
{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}
|
||||||
|
Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{data.candidates.length === 0 ? (
|
{data.candidates.length === 0 ? (
|
||||||
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
|
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -32,15 +32,16 @@
|
|||||||
.candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
.candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||||
.candidate-labels { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; color: var(--muted); font-size: .72rem; }
|
.candidate-labels { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; color: var(--muted); font-size: .72rem; }
|
||||||
.candidate-status { padding: .25rem .45rem; color: #dce8e2; background: #2b3832; border-radius: .35rem; font-weight: 800; }
|
.candidate-status { padding: .25rem .45rem; color: #dce8e2; background: #2b3832; border-radius: .35rem; font-weight: 800; }
|
||||||
|
.status-neutral { color: #dce8e2; background: #2b3832; }
|
||||||
.status-screening { color: #d6edfc; background: #243a48; }
|
.status-screening { color: #d6edfc; background: #243a48; }
|
||||||
.status-ready, .status-sending { color: #bff4d2; background: #163a26; }
|
.status-ready, .status-sending { color: var(--success-text); background: var(--success-bg); }
|
||||||
.status-retryable { color: #ffe1a0; background: #42351b; }
|
.status-retryable { color: var(--warning-text); background: #42351b; }
|
||||||
.candidate-heading time { flex: 0 0 auto; color: var(--muted); font-size: .74rem; }
|
.candidate-heading time { flex: 0 0 auto; color: var(--muted); font-size: .74rem; }
|
||||||
.candidate-title { width: fit-content; max-width: 100%; color: #eef6f1; font-size: 1rem; font-weight: 720; line-height: 1.35; text-decoration-color: #426655; text-underline-offset: .22em; overflow-wrap: anywhere; }
|
.candidate-title { width: fit-content; max-width: 100%; color: #eef6f1; font-size: 1rem; font-weight: 720; line-height: 1.35; text-decoration-color: #426655; text-underline-offset: .22em; overflow-wrap: anywhere; }
|
||||||
.candidate-title:hover { text-decoration-color: var(--accent); }
|
.candidate-title:hover { text-decoration-color: var(--accent); }
|
||||||
.candidate-meta { display: flex; flex-wrap: wrap; gap: .4rem .8rem; color: var(--muted); font-size: .76rem; }
|
.candidate-meta { display: flex; flex-wrap: wrap; gap: .4rem .8rem; color: var(--muted); font-size: .76rem; }
|
||||||
.candidate-note { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #d9c99e; background: #251f13; border-left: 2px solid var(--warning); border-radius: .35rem; font-size: .78rem; overflow-wrap: anywhere; }
|
.candidate-note { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #d9c99e; background: #251f13; border-left: 2px solid var(--warning); border-radius: .35rem; font-size: .78rem; overflow-wrap: anywhere; }
|
||||||
.candidate-note strong { color: #ffe1a0; font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
|
.candidate-note strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
.outreach-candidate .account-owner-badge { margin-top: -.1rem; }
|
.outreach-candidate .account-owner-badge { margin-top: -.1rem; }
|
||||||
|
|
||||||
@media (min-width: 64rem) {
|
@media (min-width: 64rem) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
|
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
|
||||||
import type { BotStats, Timers } from '../../api/contracts.js';
|
import type { BotStats, Timers } from '../../api/contracts.js';
|
||||||
@@ -17,6 +17,27 @@ function formatTime(timestamp: number): string {
|
|||||||
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
|
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMetricValue(value: number): string {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(remainingMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1_000));
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticks a client clock so time-derived UI flips without a refetch. */
|
||||||
|
function useNow(intervalMs = 1_000): number {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = globalThis.setInterval(() => setNow(Date.now()), intervalMs);
|
||||||
|
return () => globalThis.clearInterval(timer);
|
||||||
|
}, [intervalMs]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
|
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
|
||||||
return (
|
return (
|
||||||
<article className="metric-card">
|
<article className="metric-card">
|
||||||
@@ -28,7 +49,8 @@ function MetricCard({ label, value, detail, metric }: { label: string; value: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TimersPanel({ timers }: { timers: Timers }) {
|
function TimersPanel({ timers }: { timers: Timers }) {
|
||||||
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= Date.now();
|
const now = useNow();
|
||||||
|
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= now;
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="timers-heading" className="panel">
|
<section aria-labelledby="timers-heading" className="panel">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
@@ -41,7 +63,9 @@ function TimersPanel({ timers }: { timers: Timers }) {
|
|||||||
<dl className="definition-list">
|
<dl className="definition-list">
|
||||||
<div>
|
<div>
|
||||||
<dt>Next message</dt>
|
<dt>Next message</dt>
|
||||||
<dd>{isReady ? 'Available now' : formatTime(timers.nextMessageAvailableAt ?? Date.now())}</dd>
|
<dd>{isReady
|
||||||
|
? 'Available now'
|
||||||
|
: `Ready in ${formatCountdown((timers.nextMessageAvailableAt ?? now) - now)} (${formatTime(timers.nextMessageAvailableAt ?? now)})`}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Hourly allowance</dt>
|
<dt>Hourly allowance</dt>
|
||||||
@@ -129,8 +153,8 @@ function ActivityPanel({ stats }: { stats: BotStats }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ol className="activity-list">
|
<ol className="activity-list">
|
||||||
{stats.recentActivity.map((item) => (
|
{stats.recentActivity.map((item, index) => (
|
||||||
<li key={`${item.timestamp}-${item.message}`}>
|
<li key={`${item.timestamp}-${index}`}>
|
||||||
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
||||||
<div><strong>{item.postUrl && item.postTitle ? (
|
<div><strong>{item.postUrl && item.postTitle ? (
|
||||||
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
||||||
@@ -168,6 +192,15 @@ export function OverviewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (statsQuery.isPending) {
|
if (statsQuery.isPending) {
|
||||||
|
if (statsQuery.isPaused) {
|
||||||
|
return (
|
||||||
|
<div className="page-state error-state" role="alert">
|
||||||
|
<strong>You appear to be offline</strong>
|
||||||
|
<span>The overview will load once the connection returns.</span>
|
||||||
|
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
|
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +227,7 @@ export function OverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button
|
<button
|
||||||
|
aria-busy={isRefreshing ? 'true' : undefined}
|
||||||
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
|
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
@@ -203,7 +237,6 @@ export function OverviewPage() {
|
|||||||
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
|
|
||||||
className={action === 'start' ? 'button primary' : 'button danger'}
|
className={action === 'start' ? 'button primary' : 'button danger'}
|
||||||
onClick={() => setConfirmationAction(action)}
|
onClick={() => setConfirmationAction(action)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -232,10 +265,10 @@ export function OverviewPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section aria-label="Key metrics" className="metrics-grid">
|
<section aria-label="Key metrics" className="metrics-grid">
|
||||||
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={stats.totalMessagesSent} />
|
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={formatMetricValue(stats.totalMessagesSent)} />
|
||||||
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={stats.totalResponses} />
|
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={formatMetricValue(stats.totalResponses)} />
|
||||||
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={stats.totalProcessed} />
|
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={formatMetricValue(stats.totalProcessed)} />
|
||||||
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
|
<MetricCard detail={`Limit ${formatMetricValue(stats.maxMessagesPerHour)}`} label="This hour" metric="messages-hour" value={formatMetricValue(stats.messagesThisHour)} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AccountStatusPanel accounts={stats.accounts} />
|
<AccountStatusPanel accounts={stats.accounts} />
|
||||||
@@ -256,12 +289,12 @@ export function OverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<RecentMessagesPanel />
|
<RecentMessagesPanel />
|
||||||
|
|
||||||
{controlMutation.isError ? <div className="toast error" role="alert">{errorMessage(controlMutation.error)}</div> : null}
|
|
||||||
{confirmationAction ? (
|
{confirmationAction ? (
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
action={confirmationAction}
|
action={confirmationAction}
|
||||||
|
errorMessage={controlMutation.isError ? errorMessage(controlMutation.error) : null}
|
||||||
isPending={controlMutation.isPending}
|
isPending={controlMutation.isPending}
|
||||||
onCancel={() => setConfirmationAction(null)}
|
onCancel={() => { setConfirmationAction(null); controlMutation.reset(); }}
|
||||||
onConfirm={confirmControl}
|
onConfirm={confirmControl}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -20,9 +20,20 @@ export function RecentMessagesPanel() {
|
|||||||
return (
|
return (
|
||||||
<section aria-labelledby="recent-messages-heading" className="panel">
|
<section aria-labelledby="recent-messages-heading" className="panel">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">Durable records</span><h2 id="recent-messages-heading">Recent sent messages</h2></div></div>
|
<div className="panel-heading"><div><span className="eyebrow">Durable records</span><h2 id="recent-messages-heading">Recent sent messages</h2></div></div>
|
||||||
{query.isPending ? <p role="status">Loading recent messages...</p> : null}
|
{query.isPending ? <div className="panel-loading" role="status"><span className="spinner" />Loading recent messages…</div> : null}
|
||||||
{query.isError ? <div role="alert"><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry messages</button></div> : null}
|
{query.isError ? (
|
||||||
{query.data?.length === 0 ? <p>No sent messages recorded yet.</p> : null}
|
<div className="error-panel" role="alert">
|
||||||
|
<strong>Recent messages unavailable</strong>
|
||||||
|
<span>{query.error.message}</span>
|
||||||
|
<button className="button secondary" onClick={() => void query.refetch()} type="button">Retry messages</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{query.data?.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>No sent messages recorded yet.</strong>
|
||||||
|
<span>Delivered messages will appear here.</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{query.data && query.data.length > 0 ? (
|
{query.data && query.data.length > 0 ? (
|
||||||
<ol aria-label="Recent sent messages" className="activity-list">
|
<ol aria-label="Recent sent messages" className="activity-list">
|
||||||
{query.data.map((message) => (
|
{query.data.map((message) => (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import type { UseQueryResult } from '@tanstack/react-query';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import type {
|
import type {
|
||||||
DailyTrendResponseSchema,
|
DailyTrendResponseSchema,
|
||||||
@@ -24,16 +26,43 @@ type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
|
|||||||
type Funnel = z.infer<typeof FunnelResponseSchema>;
|
type Funnel = z.infer<typeof FunnelResponseSchema>;
|
||||||
type AiUsageSummary = z.infer<typeof AiUsageSummaryResponseSchema>;
|
type AiUsageSummary = z.infer<typeof AiUsageSummaryResponseSchema>;
|
||||||
|
|
||||||
|
function formatTrendDate(isoDate: string): string {
|
||||||
|
const parsed = new Date(`${isoDate}T00:00:00`);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return isoDate;
|
||||||
|
return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders one panel's own loading, error, and data states independently of its siblings. */
|
||||||
|
function PanelBody<T>({ query, label, children }: {
|
||||||
|
query: UseQueryResult<T, Error>;
|
||||||
|
label: string;
|
||||||
|
children: (data: T) => ReactNode;
|
||||||
|
}) {
|
||||||
|
if (query.isPending) {
|
||||||
|
return <div className="panel-loading" role="status"><span className="spinner" />Loading {label}…</div>;
|
||||||
|
}
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="error-panel" role="alert">
|
||||||
|
<strong>{label} unavailable</strong>
|
||||||
|
<span>{query.error.message}</span>
|
||||||
|
<button className="button secondary" onClick={() => void query.refetch()} type="button">Retry {label}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children(query.data)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function formatUsd(value: number): string {
|
function formatUsd(value: number): string {
|
||||||
return new Intl.NumberFormat(undefined, {
|
return new Intl.NumberFormat(undefined, {
|
||||||
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4,
|
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AiUsagePanel({ usage }: { usage: AiUsageSummary }) {
|
function AiUsagePanelBody({ usage }: { usage: AiUsageSummary }) {
|
||||||
const successRate = usage.requests > 0 ? Math.round((usage.successes / usage.requests) * 100) : 0;
|
const successRate = usage.requests > 0 ? Math.round((usage.successes / usage.requests) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="ai-usage-heading" className="panel feature-panel">
|
<>
|
||||||
<div className="panel-heading"><div><span className="eyebrow">OpenRouter · last {usage.days} days</span><h2 id="ai-usage-heading">AI cost and reliability</h2></div><span className="sample-size">{usage.requests} requests</span></div>
|
<div className="panel-heading"><div><span className="eyebrow">OpenRouter · last {usage.days} days</span><h2 id="ai-usage-heading">AI cost and reliability</h2></div><span className="sample-size">{usage.requests} requests</span></div>
|
||||||
<div aria-label="AI usage totals" className="metrics-grid performance-metrics">
|
<div aria-label="AI usage totals" className="metrics-grid performance-metrics">
|
||||||
<article className="metric-card"><span>Reported cost</span><strong>{formatUsd(usage.costUsd)}</strong><small>OpenRouter usage accounting</small></article>
|
<article className="metric-card"><span>Reported cost</span><strong>{formatUsd(usage.costUsd)}</strong><small>OpenRouter usage accounting</small></article>
|
||||||
@@ -46,7 +75,7 @@ function AiUsagePanel({ usage }: { usage: AiUsageSummary }) {
|
|||||||
<tr key={model.model}><th scope="row">{model.model}</th><td>{model.requests}</td><td>{model.successes}</td><td>{model.refusals}</td><td>{model.errors}</td><td>{model.totalTokens.toLocaleString()}</td><td>{formatUsd(model.costUsd)}</td><td>{model.averageLatencyMs.toLocaleString()} ms</td></tr>
|
<tr key={model.model}><th scope="row">{model.model}</th><td>{model.requests}</td><td>{model.successes}</td><td>{model.refusals}</td><td>{model.errors}</td><td>{model.totalTokens.toLocaleString()}</td><td>{formatUsd(model.costUsd)}</td><td>{model.averageLatencyMs.toLocaleString()} ms</td></tr>
|
||||||
))}</tbody></table></div>
|
))}</tbody></table></div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +89,7 @@ function SampleSize({ totalSent }: { totalSent: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FunnelPanel({ funnel }: { funnel: Funnel }) {
|
function FunnelPanelBody({ funnel }: { funnel: Funnel }) {
|
||||||
const stages = [
|
const stages = [
|
||||||
['Processed', funnel.processed],
|
['Processed', funnel.processed],
|
||||||
['Sent', funnel.sent],
|
['Sent', funnel.sent],
|
||||||
@@ -70,17 +99,28 @@ function FunnelPanel({ funnel }: { funnel: Funnel }) {
|
|||||||
['Images', funnel.images],
|
['Images', funnel.images],
|
||||||
] as const;
|
] as const;
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="funnel-heading" className="panel feature-panel">
|
<>
|
||||||
<div className="panel-heading"><div><span className="eyebrow">Qualified engagement</span><h2 id="funnel-heading">Outcome funnel</h2></div><span className="sample-size">{funnel.pending} awaiting maturity</span></div>
|
<div className="panel-heading"><div><span className="eyebrow">Qualified engagement</span><h2 id="funnel-heading">Outcome funnel</h2></div><span className="sample-size">{funnel.pending} awaiting maturity</span></div>
|
||||||
<ol className="funnel-list">
|
<ol className="funnel-list">
|
||||||
{stages.map(([label, value]) => <li key={label}><span>{label}</span><strong>{value}</strong></li>)}
|
{stages.map(([label, value], index) => {
|
||||||
|
const previousValue = index > 0 ? stages[index - 1][1] : null;
|
||||||
|
const conversion = previousValue !== null && previousValue > 0
|
||||||
|
? `${Math.round((value / previousValue) * 100)}% of previous`
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<li key={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="funnel-value"><strong>{value.toLocaleString()}</strong>{conversion ? <small className="interval">{conversion}</small> : null}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
<div className="guardrail-list" aria-label="Guardrail outcomes">
|
<div className="guardrail-list" aria-label="Guardrail outcomes">
|
||||||
<span>Declined <strong>{funnel.declined}</strong></span>
|
<span>Declined <strong>{funnel.declined}</strong></span>
|
||||||
<span>Boundary <strong>{funnel.boundary}</strong></span>
|
<span>Boundary <strong>{funnel.boundary}</strong></span>
|
||||||
<span>Upsell <strong>{funnel.upsell}</strong></span>
|
<span>Upsell <strong>{funnel.upsell}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +133,14 @@ function PerformanceTable({
|
|||||||
rows: readonly (SubredditPerformance | StrategyPerformance)[];
|
rows: readonly (SubredditPerformance | StrategyPerformance)[];
|
||||||
rowLabel: (row: SubredditPerformance | StrategyPerformance) => string;
|
rowLabel: (row: SubredditPerformance | StrategyPerformance) => string;
|
||||||
}) {
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>No sends recorded yet.</strong>
|
||||||
|
<span>Rows appear once messages have been sent.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table aria-label={label} className="performance-table">
|
<table aria-label={label} className="performance-table">
|
||||||
@@ -117,12 +165,20 @@ function PerformanceTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Trend({ rows }: { rows: readonly DailyTrend[] }) {
|
function Trend({ rows, rangeDays }: { rows: readonly DailyTrend[]; rangeDays: number }) {
|
||||||
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<ol aria-label="30 day response trend" className="trend-list">
|
<div className="empty-state">
|
||||||
|
<strong>No sends recorded yet.</strong>
|
||||||
|
<span>Daily results appear once messages have been sent.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ol aria-label={`${rangeDays} day response trend`} className="trend-list">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<li key={row.date}>
|
<li key={row.date}>
|
||||||
<time dateTime={row.date}>{row.date}</time>
|
<time dateTime={row.date}>{formatTrendDate(row.date)}</time>
|
||||||
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
|
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
|
||||||
<strong>{row.responseRate}%</strong>
|
<strong>{row.responseRate}%</strong>
|
||||||
<span>{row.totalImages} images ({row.imageRate}%)</span>
|
<span>{row.totalImages} images ({row.imageRate}%)</span>
|
||||||
@@ -152,33 +208,66 @@ function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TREND_RANGES = [7, 30, 90] as const;
|
||||||
|
type TrendRange = (typeof TREND_RANGES)[number];
|
||||||
|
|
||||||
export function PerformancePage() {
|
export function PerformancePage() {
|
||||||
|
const [rangeDays, setRangeDays] = useState<TrendRange>(30);
|
||||||
const subreddits = useSubredditPerformanceQuery();
|
const subreddits = useSubredditPerformanceQuery();
|
||||||
const strategies = useStrategyPerformanceQuery();
|
const strategies = useStrategyPerformanceQuery();
|
||||||
const trend = useDailyTrendQuery(30);
|
const trend = useDailyTrendQuery(rangeDays);
|
||||||
const examples = useSuccessfulExamplesQuery();
|
const examples = useSuccessfulExamplesQuery();
|
||||||
const funnel = useFunnelQuery();
|
const funnel = useFunnelQuery();
|
||||||
const aiUsage = useAiUsageQuery(30);
|
const aiUsage = useAiUsageQuery(rangeDays);
|
||||||
const queries = [subreddits, strategies, trend, examples, funnel, aiUsage];
|
|
||||||
if (queries.some((query) => query.isPending)) {
|
|
||||||
return <div className="page-state" role="status">Loading performance…</div>;
|
|
||||||
}
|
|
||||||
const failed = queries.find((query) => query.isError);
|
|
||||||
if (failed?.error) {
|
|
||||||
return <div className="page-state error-state" role="alert"><strong>Performance unavailable</strong><span>{failed.error.message}</span></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page performance-page">
|
<div className="page performance-page">
|
||||||
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div></header>
|
<header className="page-header">
|
||||||
{funnel.data ? <FunnelPanel funnel={funnel.data} /> : null}
|
<div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div>
|
||||||
{aiUsage.data ? <AiUsagePanel usage={aiUsage.data} /> : null}
|
<div aria-label="Date range" className="header-actions" role="group">
|
||||||
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
|
{TREND_RANGES.map((days) => (
|
||||||
<div className="performance-grid">
|
<button
|
||||||
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
|
aria-pressed={rangeDays === days}
|
||||||
<section aria-labelledby="strategy-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="strategy-heading">By strategy</h2></div><PerformanceTable label="Strategy performance" rows={strategies.data ?? []} rowLabel={(row) => 'strategyId' in row ? row.strategyId : row.subreddit} /></section>
|
className={rangeDays === days ? 'button primary' : 'button secondary'}
|
||||||
|
key={days}
|
||||||
|
onClick={() => setRangeDays(days)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{days} days
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<section aria-labelledby="examples-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div><SuccessfulExamples examples={examples.data ?? []} /></section>
|
</header>
|
||||||
|
<section aria-labelledby="funnel-heading-anchor" className="panel feature-panel">
|
||||||
|
<span className="sr-only" id="funnel-heading-anchor">Outcome funnel</span>
|
||||||
|
<PanelBody label="outcome funnel" query={funnel}>{(data) => <FunnelPanelBody funnel={data} />}</PanelBody>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="ai-usage-heading-anchor" className="panel feature-panel">
|
||||||
|
<span className="sr-only" id="ai-usage-heading-anchor">AI cost and reliability</span>
|
||||||
|
<PanelBody label="AI usage" query={aiUsage}>{(data) => <AiUsagePanelBody usage={data} />}</PanelBody>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="trend-heading" className="panel feature-panel">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">Last {rangeDays} days</span><h2 id="trend-heading">Response trend</h2></div></div>
|
||||||
|
<PanelBody label="response trend" query={trend}>{(data) => <Trend rangeDays={rangeDays} rows={data} />}</PanelBody>
|
||||||
|
</section>
|
||||||
|
<div className="performance-grid">
|
||||||
|
<section aria-labelledby="subreddit-heading" className="panel feature-panel">
|
||||||
|
<div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div>
|
||||||
|
<PanelBody label="subreddit performance" query={subreddits}>
|
||||||
|
{(data) => <PerformanceTable label="Subreddit performance" rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} rows={data} />}
|
||||||
|
</PanelBody>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="strategy-heading" className="panel feature-panel">
|
||||||
|
<div className="panel-heading"><h2 id="strategy-heading">By strategy</h2></div>
|
||||||
|
<PanelBody label="strategy performance" query={strategies}>
|
||||||
|
{(data) => <PerformanceTable label="Strategy performance" rowLabel={(row) => 'strategyId' in row ? row.strategyId : row.subreddit} rows={data} />}
|
||||||
|
</PanelBody>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<section aria-labelledby="examples-heading" className="panel feature-panel">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div>
|
||||||
|
<PanelBody label="successful examples" query={examples}>{(data) => <SuccessfulExamples examples={data} />}</PanelBody>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,17 @@
|
|||||||
.interval { display: block; margin-top: .2rem; color: var(--muted); white-space: nowrap; }
|
.interval { display: block; margin-top: .2rem; color: var(--muted); white-space: nowrap; }
|
||||||
.funnel-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; padding: 0; margin: 0; list-style: none; }
|
.funnel-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; padding: 0; margin: 0; list-style: none; }
|
||||||
.funnel-list li { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
|
.funnel-list li { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
|
||||||
|
.funnel-value { display: grid; gap: .1rem; text-align: right; }
|
||||||
.guardrail-list { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .7rem; color: var(--muted); }
|
.guardrail-list { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .7rem; color: var(--muted); }
|
||||||
.guardrail-list span { padding: .45rem .65rem; border: 1px solid var(--border); border-radius: 999px; }
|
.guardrail-list span { padding: .45rem .65rem; border: 1px solid var(--border); border-radius: 999px; }
|
||||||
.trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
|
.trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
|
||||||
.trend-list li { display: grid; grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 8rem 6rem; align-items: center; gap: .75rem; }
|
/* Narrow screens: wrap each day into two rows instead of forcing a wide grid. */
|
||||||
|
.trend-list li { display: grid; grid-template-columns: 4.5rem minmax(4rem, 1fr) 3rem; align-items: center; gap: .4rem .75rem; }
|
||||||
|
.trend-list li > span:not(.trend-track), .trend-list li .sample-size { grid-column: span 3; font-size: .78rem; }
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.trend-list li { grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 8rem 6rem; gap: .75rem; }
|
||||||
|
.trend-list li > span:not(.trend-track), .trend-list li .sample-size { grid-column: auto; font-size: inherit; }
|
||||||
|
}
|
||||||
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
|
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
|
||||||
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
|
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
|
||||||
.example-grid { display: grid; gap: .75rem; }
|
.example-grid { display: grid; gap: .75rem; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import {
|
import {
|
||||||
useImportRedditSessionMutation,
|
useImportRedditSessionMutation,
|
||||||
@@ -27,16 +27,27 @@ export function AccessPanel() {
|
|||||||
const accountsQuery = useRedditAccountsQuery();
|
const accountsQuery = useRedditAccountsQuery();
|
||||||
const importMutation = useImportRedditSessionMutation();
|
const importMutation = useImportRedditSessionMutation();
|
||||||
const importInput = useRef<HTMLInputElement>(null);
|
const importInput = useRef<HTMLInputElement>(null);
|
||||||
|
const [importFileError, setImportFileError] = useState<string | null>(null);
|
||||||
|
const resetImportMutation = importMutation.reset;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!importMutation.isSuccess) return;
|
||||||
|
const timer = globalThis.setTimeout(() => resetImportMutation(), 5_000);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [importMutation.isSuccess, resetImportMutation]);
|
||||||
|
|
||||||
async function importSession(file: File | undefined) {
|
async function importSession(file: File | undefined) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
setImportFileError(null);
|
||||||
|
let exported: unknown;
|
||||||
try {
|
try {
|
||||||
const exported: unknown = JSON.parse(await file.text());
|
exported = JSON.parse(await file.text());
|
||||||
importMutation.mutate(exported);
|
|
||||||
} catch {
|
} catch {
|
||||||
importMutation.reset();
|
importMutation.reset();
|
||||||
importMutation.mutate({});
|
setImportFileError("That file isn't valid JSON. Export a fresh session dump from the extension and try again.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
importMutation.mutate(exported);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusQuery.isPending) {
|
if (statusQuery.isPending) {
|
||||||
@@ -60,6 +71,13 @@ export function AccessPanel() {
|
|||||||
<p className="settings-copy">Add each Reddit account with its own extension session dump. Content and sync state stay owned by that account in the database. Any account with an expired login pauses while waiting for a fresh session dump; other accounts keep running.</p>
|
<p className="settings-copy">Add each Reddit account with its own extension session dump. Content and sync state stay owned by that account in the database. Any account with an expired login pauses while waiting for a fresh session dump; other accounts keep running.</p>
|
||||||
{username ? <p className="settings-copy">Verified account: <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
|
{username ? <p className="settings-copy">Verified account: <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
|
||||||
<div aria-label="Reddit accounts" className="reddit-account-list">
|
<div aria-label="Reddit accounts" className="reddit-account-list">
|
||||||
|
{accountsQuery.isPending ? <p className="settings-copy" role="status"><span className="spinner" /> Loading Reddit accounts…</p> : null}
|
||||||
|
{accountsQuery.isError ? (
|
||||||
|
<div className="settings-notice error" role="alert">
|
||||||
|
<span>Could not load Reddit accounts: {errorMessage(accountsQuery.error)}</span>{' '}
|
||||||
|
<button className="button secondary" onClick={() => void accountsQuery.refetch()} type="button">Retry accounts</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{(accountsQuery.data ?? []).map((account) => (
|
{(accountsQuery.data ?? []).map((account) => (
|
||||||
<div className={account.isRunning ? 'reddit-account-row is-active' : 'reddit-account-row'} key={account.accountId}>
|
<div className={account.isRunning ? 'reddit-account-row is-active' : 'reddit-account-row'} key={account.accountId}>
|
||||||
<div><strong><RedditUsernameLink username={account.redditUsername} /></strong><span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span></div>
|
<div><strong><RedditUsernameLink username={account.redditUsername} /></strong><span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span></div>
|
||||||
@@ -69,11 +87,24 @@ export function AccessPanel() {
|
|||||||
{accountsQuery.isSuccess && accountsQuery.data.length === 0 ? <p className="settings-copy">No Reddit accounts have been added yet.</p> : null}
|
{accountsQuery.isSuccess && accountsQuery.data.length === 0 ? <p className="settings-copy">No Reddit accounts have been added yet.</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="access-actions">
|
<div className="access-actions">
|
||||||
<input accept="application/json" aria-label="Reddit session export" className="sr-only" onChange={(event) => void importSession(event.target.files?.[0])} ref={importInput} type="file" />
|
<input
|
||||||
|
accept="application/json"
|
||||||
|
aria-label="Reddit session export"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
// Reset the input so picking the same file again re-triggers change.
|
||||||
|
event.target.value = '';
|
||||||
|
void importSession(file);
|
||||||
|
}}
|
||||||
|
ref={importInput}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Adding account...' : 'Add or refresh account'}</button>
|
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Adding account...' : 'Add or refresh account'}</button>
|
||||||
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking sessions...' : 'Recheck accounts'}</button>
|
<button aria-busy={statusQuery.isFetching ? 'true' : undefined} className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking sessions...' : 'Recheck accounts'}</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="settings-copy">Open Reddit Chat in Chrome for the account you want to add, wait for the extension badge to show OK, then upload its JSON dump. It contains login credentials; do not share it.</p>
|
<p className="settings-copy">Open Reddit Chat in Chrome for the account you want to add, wait for the extension badge to show OK, then upload its JSON dump. It contains login credentials; do not share it.</p>
|
||||||
|
{importFileError ? <div className="settings-notice error" role="alert">{importFileError}</div> : null}
|
||||||
{importMutation.isSuccess ? <div className="settings-notice success" role="status"><span>Reddit session imported and verified.</span> <span>Account runtime is running independently.</span></div> : null}
|
{importMutation.isSuccess ? <div className="settings-notice success" role="status"><span>Reddit session imported and verified.</span> <span>Account runtime is running independently.</span></div> : null}
|
||||||
{importMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(importMutation.error)}</div> : null}
|
{importMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(importMutation.error)}</div> : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
interface RiskDialogProps {
|
interface RiskDialogProps {
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
@@ -8,27 +8,19 @@ interface RiskDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RiskDialog({ reasons, isPending, onCancel, onConfirm }: RiskDialogProps) {
|
export function RiskDialog({ reasons, isPending, onCancel, onConfirm }: RiskDialogProps) {
|
||||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cancelButtonRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-labelledby="risk-settings-title" aria-modal="true" className="dialog-backdrop" role="dialog">
|
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="risk-settings-title" onClose={onCancel}>
|
||||||
<div className="dialog-card">
|
|
||||||
<span className="eyebrow">Confirm operational risk</span>
|
<span className="eyebrow">Confirm operational risk</span>
|
||||||
<h2 id="risk-settings-title">Confirm risky settings</h2>
|
<h2 id="risk-settings-title">Confirm risky settings</h2>
|
||||||
<ul className="risk-list">
|
<ul className="risk-list">
|
||||||
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="dialog-actions">
|
<div className="dialog-actions">
|
||||||
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
|
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||||
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||||
{isPending ? 'Applying…' : 'Apply risky settings'}
|
{isPending ? 'Applying…' : 'Apply risky settings'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useBlocker } from 'react-router-dom';
|
||||||
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
|
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
import { AccessPanel } from './access-panel.js';
|
import { AccessPanel } from './access-panel.js';
|
||||||
import { RiskDialog } from './risk-dialog.js';
|
import { RiskDialog } from './risk-dialog.js';
|
||||||
import { useSaveSettingsMutation } from './settings-queries.js';
|
import { useSaveSettingsMutation } from './settings-queries.js';
|
||||||
import { createSettingsDraft, isDraftDirty, validateSettingsDraft, type SettingsDraft } from './settings-model.js';
|
import {
|
||||||
|
changedDraftFields,
|
||||||
|
createSettingsDraft,
|
||||||
|
isDraftDirty,
|
||||||
|
validateSettingsDraft,
|
||||||
|
type SettingsDraft,
|
||||||
|
} from './settings-model.js';
|
||||||
import { BehaviourSection, MonitoringSection, RateLimitsSection } from './settings-sections.js';
|
import { BehaviourSection, MonitoringSection, RateLimitsSection } from './settings-sections.js';
|
||||||
import type { SettingsSection } from './settings-page.js';
|
import type { SettingsSection } from './settings-page.js';
|
||||||
|
|
||||||
@@ -21,13 +29,51 @@ function errorMessage(error: Error | null): string {
|
|||||||
return error?.message ?? 'Something went wrong.';
|
return error?.message ?? 'Something went wrong.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiscardDraftDialog({ title, body, onDiscard, onStay }: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
onDiscard(): void;
|
||||||
|
onStay(): void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal className="dialog-card" labelledBy="discard-draft-title" onClose={onStay}>
|
||||||
|
<span className="eyebrow">Unsaved changes</span>
|
||||||
|
<h2 id="discard-draft-title">{title}</h2>
|
||||||
|
<p>{body}</p>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button autoFocus className="button secondary" onClick={onStay} type="button">Stay</button>
|
||||||
|
<button className="button danger" onClick={onDiscard} type="button">Discard changes</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
||||||
const [persistedDraft, setPersistedDraft] = useState<SettingsDraft>(() => createSettingsDraft(config));
|
const [persistedDraft, setPersistedDraft] = useState<SettingsDraft>(() => createSettingsDraft(config));
|
||||||
const [draft, setDraft] = useState<SettingsDraft>(persistedDraft);
|
const [draft, setDraft] = useState<SettingsDraft>(persistedDraft);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [pendingRiskySave, setPendingRiskySave] = useState<PendingRiskySave | null>(null);
|
const [pendingRiskySave, setPendingRiskySave] = useState<PendingRiskySave | null>(null);
|
||||||
|
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
|
||||||
|
const [serverChangedNotice, setServerChangedNotice] = useState(false);
|
||||||
|
const [focusFirstErrorToken, setFocusFirstErrorToken] = useState(0);
|
||||||
const saveMutation = useSaveSettingsMutation();
|
const saveMutation = useSaveSettingsMutation();
|
||||||
const isDirty = isDraftDirty(draft, persistedDraft);
|
const isDirty = isDraftDirty(draft, persistedDraft);
|
||||||
|
const seededConfig = useRef(config);
|
||||||
|
|
||||||
|
// Reconcile server refetches: re-seed a clean editor, warn a dirty one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (seededConfig.current === config) return;
|
||||||
|
seededConfig.current = config;
|
||||||
|
const serverDraft = createSettingsDraft(config);
|
||||||
|
if (!isDraftDirty(serverDraft, persistedDraft)) return;
|
||||||
|
setPersistedDraft(serverDraft);
|
||||||
|
if (isDraftDirty(draft, persistedDraft)) {
|
||||||
|
setServerChangedNotice(true);
|
||||||
|
} else {
|
||||||
|
setDraft(serverDraft);
|
||||||
|
setServerChangedNotice(false);
|
||||||
|
}
|
||||||
|
}, [config, draft, persistedDraft]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
@@ -39,6 +85,18 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
|||||||
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
||||||
}, [isDirty]);
|
}, [isDirty]);
|
||||||
|
|
||||||
|
// Guard in-app navigation away from settings while the draft is dirty.
|
||||||
|
// Section switches only change the query string, so they stay unblocked.
|
||||||
|
const navigationBlocker = useBlocker(({ currentLocation, nextLocation }) =>
|
||||||
|
isDirty && currentLocation.pathname !== nextLocation.pathname);
|
||||||
|
|
||||||
|
// After a failed save attempt, move focus to the first invalid control on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusFirstErrorToken === 0) return;
|
||||||
|
const firstInvalid = document.querySelector<HTMLElement>('[aria-invalid="true"]');
|
||||||
|
firstInvalid?.focus();
|
||||||
|
}, [focusFirstErrorToken]);
|
||||||
|
|
||||||
function applySavedResponse(response: Awaited<ReturnType<typeof saveMutation.mutateAsync>>) {
|
function applySavedResponse(response: Awaited<ReturnType<typeof saveMutation.mutateAsync>>) {
|
||||||
const nextConfig: ConfigResponse = {
|
const nextConfig: ConfigResponse = {
|
||||||
...response.config,
|
...response.config,
|
||||||
@@ -50,6 +108,7 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
|||||||
setPersistedDraft(nextDraft);
|
setPersistedDraft(nextDraft);
|
||||||
setDraft(nextDraft);
|
setDraft(nextDraft);
|
||||||
setPendingRiskySave(null);
|
setPendingRiskySave(null);
|
||||||
|
setServerChangedNotice(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit(payload: ConfigUpdateRequest) {
|
function submit(payload: ConfigUpdateRequest) {
|
||||||
@@ -58,15 +117,32 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
|||||||
|
|
||||||
function updateDraft(nextDraft: SettingsDraft) {
|
function updateDraft(nextDraft: SettingsDraft) {
|
||||||
saveMutation.reset();
|
saveMutation.reset();
|
||||||
setErrors({});
|
const editedFields = changedDraftFields(draft, nextDraft);
|
||||||
|
setErrors((currentErrors) => {
|
||||||
|
if (editedFields.length === 0) return currentErrors;
|
||||||
|
const next = { ...currentErrors };
|
||||||
|
for (const field of editedFields) delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setDraft(nextDraft);
|
setDraft(nextDraft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetDraft() {
|
||||||
|
setDraft(persistedDraft);
|
||||||
|
setErrors({});
|
||||||
|
setServerChangedNotice(false);
|
||||||
|
setIsResetConfirmOpen(false);
|
||||||
|
saveMutation.reset();
|
||||||
|
}
|
||||||
|
|
||||||
function prepareSave() {
|
function prepareSave() {
|
||||||
saveMutation.reset();
|
saveMutation.reset();
|
||||||
const validation = validateSettingsDraft(draft);
|
const validation = validateSettingsDraft(draft);
|
||||||
setErrors(validation.errors);
|
setErrors(validation.errors);
|
||||||
if (!validation.data) return;
|
if (!validation.data) {
|
||||||
|
setFocusFirstErrorToken((token) => token + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
if (persistedDraft.dryRun && !validation.data.dryRun) {
|
if (persistedDraft.dryRun && !validation.data.dryRun) {
|
||||||
@@ -76,9 +152,15 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
|||||||
if (validation.data.maxMessagesPerHour > 20 && validation.data.maxMessagesPerHour > persistedLimit) {
|
if (validation.data.maxMessagesPerHour > 20 && validation.data.maxMessagesPerHour > persistedLimit) {
|
||||||
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
|
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
|
||||||
}
|
}
|
||||||
const enablesAutonomousR4R = validation.data.payload.subreddits?.some((subreddit, index) => (
|
// Match persisted rows BY NAME: row insertion or reordering must never make
|
||||||
|
// an autonomous mode look pre-approved because of a shifted index.
|
||||||
|
const persistedModeByName = new Map(persistedDraft.subreddits.map((subreddit) => [
|
||||||
|
subreddit.name.trim().toLowerCase(),
|
||||||
|
subreddit.conversationMode,
|
||||||
|
]));
|
||||||
|
const enablesAutonomousR4R = validation.data.payload.subreddits?.some((subreddit) => (
|
||||||
(subreddit.conversation.mode === 'r4r' || subreddit.conversation.mode === 'r4r-media')
|
(subreddit.conversation.mode === 'r4r' || subreddit.conversation.mode === 'r4r-media')
|
||||||
&& persistedDraft.subreddits[index]?.conversationMode !== subreddit.conversation.mode
|
&& persistedModeByName.get(subreddit.name.trim().toLowerCase()) !== subreddit.conversation.mode
|
||||||
));
|
));
|
||||||
if (enablesAutonomousR4R) {
|
if (enablesAutonomousR4R) {
|
||||||
reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. The hybrid mode can make one optional image request after rapport. Existing conversations keep their snapshotted policy.');
|
reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. The hybrid mode can make one optional image request after rapport. Existing conversations keep their snapshotted policy.');
|
||||||
@@ -91,6 +173,8 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
|||||||
submit(validation.data.payload);
|
submit(validation.data.payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const errorCount = Object.keys(errors).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{section === 'monitoring' ? <MonitoringSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
|
{section === 'monitoring' ? <MonitoringSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
|
||||||
@@ -102,12 +186,18 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
|||||||
<div className="settings-save-bar">
|
<div className="settings-save-bar">
|
||||||
<div aria-live="polite">
|
<div aria-live="polite">
|
||||||
{isDirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
|
{isDirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
|
||||||
|
{serverChangedNotice ? <strong className="save-warning" role="status">Settings changed on the server — Reset to load them.</strong> : null}
|
||||||
|
{errorCount > 0 ? (
|
||||||
|
<strong className="save-error" role="alert">
|
||||||
|
{errorCount === 1 ? 'Resolve 1 validation error before saving.' : `Resolve ${errorCount} validation errors before saving.`}
|
||||||
|
</strong>
|
||||||
|
) : null}
|
||||||
{saveMutation.isSuccess ? <strong className="save-success">Settings saved.</strong> : null}
|
{saveMutation.isSuccess ? <strong className="save-success">Settings saved.</strong> : null}
|
||||||
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
|
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button className="button secondary" disabled={!isDirty || saveMutation.isPending} onClick={() => { setDraft(persistedDraft); setErrors({}); saveMutation.reset(); }} type="button">Reset changes</button>
|
<button className="button secondary" disabled={(!isDirty && !serverChangedNotice) || saveMutation.isPending} onClick={() => { if (isDirty) setIsResetConfirmOpen(true); else resetDraft(); }} type="button">Reset changes</button>
|
||||||
<button aria-label={saveMutation.isPending ? 'Saving settings' : 'Save settings'} className="button primary" disabled={!isDirty || saveMutation.isPending} onClick={prepareSave} type="button">{saveMutation.isPending ? 'Saving…' : 'Save settings'}</button>
|
<button aria-busy={saveMutation.isPending ? 'true' : undefined} aria-label={saveMutation.isPending ? 'Saving settings' : 'Save settings'} className="button primary" disabled={!isDirty || saveMutation.isPending} onClick={prepareSave} type="button">{saveMutation.isPending ? 'Saving…' : 'Save settings'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -120,6 +210,22 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
|||||||
reasons={pendingRiskySave.reasons}
|
reasons={pendingRiskySave.reasons}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isResetConfirmOpen ? (
|
||||||
|
<DiscardDraftDialog
|
||||||
|
body="Your unsaved edits will be replaced with the last saved settings."
|
||||||
|
onDiscard={resetDraft}
|
||||||
|
onStay={() => setIsResetConfirmOpen(false)}
|
||||||
|
title="Discard unsaved settings?"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{navigationBlocker.state === 'blocked' ? (
|
||||||
|
<DiscardDraftDialog
|
||||||
|
body="Leaving this page will discard your unsaved settings."
|
||||||
|
onDiscard={() => navigationBlocker.proceed()}
|
||||||
|
onStay={() => navigationBlocker.reset()}
|
||||||
|
title="Discard unsaved settings?"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
|
||||||
export interface SubredditDraft {
|
export interface SubredditDraft {
|
||||||
|
/** Stable client-side row identity for React keys; excluded from dirty comparison. */
|
||||||
|
rowId: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
type: 'image' | 'chat';
|
type: 'image' | 'chat';
|
||||||
@@ -63,6 +65,13 @@ const ValidatedSettingsSchema = z.strictObject({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let nextRowId = 0;
|
||||||
|
|
||||||
|
export function createSubredditRowId(): string {
|
||||||
|
nextRowId += 1;
|
||||||
|
return `subreddit-row-${nextRowId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
|
export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
|
||||||
return {
|
return {
|
||||||
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
|
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
|
||||||
@@ -73,6 +82,7 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
|
|||||||
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
||||||
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
|
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
|
||||||
subreddits: config.subreddits.map((subreddit) => ({
|
subreddits: config.subreddits.map((subreddit) => ({
|
||||||
|
rowId: createSubredditRowId(),
|
||||||
name: subreddit.name,
|
name: subreddit.name,
|
||||||
enabled: subreddit.enabled,
|
enabled: subreddit.enabled,
|
||||||
type: subreddit.type,
|
type: subreddit.type,
|
||||||
@@ -107,18 +117,18 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
requiredErrors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] = 'Rapport turns is required.';
|
requiredErrors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] = 'Rapport turns is required.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
|
|
||||||
|
|
||||||
let advanced: Record<string, unknown> | undefined;
|
let advanced: Record<string, unknown> | undefined;
|
||||||
|
let advancedError: string | undefined;
|
||||||
if (draft.advancedConfigJson.trim() !== '') {
|
if (draft.advancedConfigJson.trim() !== '') {
|
||||||
try {
|
try {
|
||||||
const value: unknown = JSON.parse(draft.advancedConfigJson);
|
const value: unknown = JSON.parse(draft.advancedConfigJson);
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
return { errors: { advancedConfigJson: 'Advanced configuration must be a JSON object.' } };
|
advancedError = 'Advanced configuration must be a JSON object.';
|
||||||
}
|
} else {
|
||||||
advanced = value as Record<string, unknown>;
|
advanced = value as Record<string, unknown>;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { errors: { advancedConfigJson: 'Advanced configuration is not valid JSON.' } };
|
advancedError = 'Advanced configuration is not valid JSON.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,16 +159,20 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) {
|
// Run required-field and schema validation in one pass so every problem
|
||||||
|
// surfaces on the first save attempt. Required-field messages win per field.
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
|
if (!parsed.success) {
|
||||||
for (const issue of parsed.error.issues) {
|
for (const issue of parsed.error.issues) {
|
||||||
const pathParts = [...issue.path];
|
const pathParts = [...issue.path];
|
||||||
if (pathParts[0] === 'subreddits' && pathParts[2] === 'conversation') pathParts.splice(2, 1);
|
if (pathParts[0] === 'subreddits' && pathParts[2] === 'conversation') pathParts.splice(2, 1);
|
||||||
const path = pathParts.join('.');
|
const path = pathParts.join('.');
|
||||||
if (!(path in errors)) errors[path] = issue.message;
|
if (!(path in errors)) errors[path] = issue.message;
|
||||||
}
|
}
|
||||||
return { errors };
|
|
||||||
}
|
}
|
||||||
|
if (advancedError) errors.advancedConfigJson = advancedError;
|
||||||
|
Object.assign(errors, requiredErrors);
|
||||||
|
if (!parsed.success || Object.keys(errors).length > 0) return { errors };
|
||||||
|
|
||||||
const payload = ConfigUpdateRequestSchema.parse({
|
const payload = ConfigUpdateRequestSchema.parse({
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
@@ -186,6 +200,46 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function comparableDraft(draft: SettingsDraft): unknown {
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
subreddits: draft.subreddits.map(({ rowId: _rowId, ...subreddit }) => subreddit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): boolean {
|
export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): boolean {
|
||||||
return JSON.stringify(draft) !== JSON.stringify(persisted);
|
return JSON.stringify(comparableDraft(draft)) !== JSON.stringify(comparableDraft(persisted));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field keys (matching validation error keys) whose values differ between two drafts.
|
||||||
|
* Used to clear validation errors only for the fields the user edited.
|
||||||
|
*/
|
||||||
|
export function changedDraftFields(previous: SettingsDraft, next: SettingsDraft): string[] {
|
||||||
|
const changed: string[] = [];
|
||||||
|
const topLevelKeys = ['minDelayMinutes', 'maxMessagesPerHour', 'jitterSeconds', 'dryRun', 'prioritizeRecentPosts', 'recentWindowMinutes', 'maxReadyCandidates', 'advancedConfigJson'] as const;
|
||||||
|
for (const key of topLevelKeys) {
|
||||||
|
if (previous[key] !== next[key]) changed.push(key);
|
||||||
|
}
|
||||||
|
if (previous.subreddits.length !== next.subreddits.length) {
|
||||||
|
changed.push('subreddits');
|
||||||
|
// Row indices shift on add/remove, so any indexed subreddit error may be stale.
|
||||||
|
for (let index = 0; index < Math.max(previous.subreddits.length, next.subreddits.length); index += 1) {
|
||||||
|
for (const field of ['name', 'weight', 'maxAutomatedTurns', 'rapportTurnsBeforeMediaAsk'] as const) {
|
||||||
|
changed.push(`subreddits.${index}.${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
next.subreddits.forEach((subreddit, index) => {
|
||||||
|
const previousSubreddit = previous.subreddits[index];
|
||||||
|
if (!previousSubreddit) return;
|
||||||
|
for (const field of ['name', 'enabled', 'type', 'weight', 'tone', 'scripted', 'conversationMode', 'maxAutomatedTurns', 'rapportTurnsBeforeMediaAsk'] as const) {
|
||||||
|
if (previousSubreddit[field] !== subreddit[field]) {
|
||||||
|
changed.push(`subreddits.${index}.${field}`);
|
||||||
|
if (field === 'name') changed.push('subreddits');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import type { SettingsDraft, SubredditDraft } from './settings-model.js';
|
import { createSubredditRowId, type SettingsDraft, type SubredditDraft } from './settings-model.js';
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
draft: SettingsDraft;
|
draft: SettingsDraft;
|
||||||
@@ -130,7 +130,7 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
|
|||||||
...draft,
|
...draft,
|
||||||
subreddits: [
|
subreddits: [
|
||||||
...draft.subreddits,
|
...draft.subreddits,
|
||||||
{ name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3', rapportTurnsBeforeMediaAsk: '3' },
|
{ rowId: createSubredditRowId(), name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3', rapportTurnsBeforeMediaAsk: '3' },
|
||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -141,7 +141,7 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
|
|||||||
{errors.subreddits ? <p className="field-error" role="alert">{errors.subreddits}</p> : null}
|
{errors.subreddits ? <p className="field-error" role="alert">{errors.subreddits}</p> : null}
|
||||||
<div className="subreddit-list">
|
<div className="subreddit-list">
|
||||||
{draft.subreddits.map((subreddit, index) => (
|
{draft.subreddits.map((subreddit, index) => (
|
||||||
<fieldset className="subreddit-row" key={index}>
|
<fieldset className="subreddit-row" key={subreddit.rowId}>
|
||||||
<legend>Subreddit {index + 1}</legend>
|
<legend>Subreddit {index + 1}</legend>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
|
|||||||
@@ -28,13 +28,14 @@
|
|||||||
.settings-save-bar { position: sticky; bottom: 1rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 1rem; margin-top: 1rem; background: rgba(23, 35, 30, .97); border: 1px solid var(--border); border-radius: .8rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .3); }
|
.settings-save-bar { position: sticky; bottom: 1rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 1rem; margin-top: 1rem; background: rgba(23, 35, 30, .97); border: 1px solid var(--border); border-radius: .8rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .3); }
|
||||||
.settings-save-bar > div:first-child { display: grid; gap: .15rem; color: var(--muted); }
|
.settings-save-bar > div:first-child { display: grid; gap: .15rem; color: var(--muted); }
|
||||||
.save-success { color: var(--accent); }
|
.save-success { color: var(--accent); }
|
||||||
|
.save-warning { color: var(--warning); }
|
||||||
.risk-list { display: grid; gap: .5rem; padding-left: 1.2rem; color: var(--warning); }
|
.risk-list { display: grid; gap: .5rem; padding-left: 1.2rem; color: var(--warning); }
|
||||||
.access-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
|
.access-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
|
||||||
.access-actions a { display: inline-flex; align-items: center; text-decoration: none; }
|
.access-actions a { display: inline-flex; align-items: center; text-decoration: none; }
|
||||||
.settings-notice { padding: .75rem; border-radius: .65rem; }
|
.settings-notice { padding: .75rem; border-radius: .65rem; }
|
||||||
.settings-notice.success { color: #aaf0c4; background: #163a26; }
|
.settings-notice.success { color: var(--success-text); background: var(--success-bg); }
|
||||||
.settings-notice.warning { color: #ffe1a0; background: #42351b; }
|
.settings-notice.warning { color: var(--warning-text); background: var(--warning-bg); }
|
||||||
.settings-notice.error { color: #ffd1ce; background: #461b18; }
|
.settings-notice.error { color: var(--danger-text); background: var(--danger-bg); }
|
||||||
|
|
||||||
@media (min-width: 42rem) {
|
@media (min-width: 42rem) {
|
||||||
.settings-navigation { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.settings-navigation { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
|
|
||||||
.reddit-account-row {
|
.reddit-account-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--border-color, #d9d9d9);
|
border: 1px solid var(--border);
|
||||||
border-radius: .75rem;
|
border-radius: .75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -62,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reddit-account-row.is-active {
|
.reddit-account-row.is-active {
|
||||||
border-color: var(--accent, #ff6534);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reddit-account-row > div {
|
.reddit-account-row > div {
|
||||||
|
|||||||
@@ -12,13 +12,24 @@
|
|||||||
--accent-dark: #153c28;
|
--accent-dark: #153c28;
|
||||||
--danger: #ff8a84;
|
--danger: #ff8a84;
|
||||||
--warning: #f3c76b;
|
--warning: #f3c76b;
|
||||||
|
--success-text: #aaf0c4;
|
||||||
|
--success-bg: #163a26;
|
||||||
|
--success-border: #28593d;
|
||||||
|
--warning-text: #ffe1a0;
|
||||||
|
--warning-bg: #2a2111;
|
||||||
|
--warning-border: #59451e;
|
||||||
|
--danger-text: #ffd1ce;
|
||||||
|
--danger-bg: #461b18;
|
||||||
|
--danger-border: #80352f;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||||
button, input { font: inherit; }
|
button, input { font: inherit; }
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
button:disabled { cursor: wait; opacity: .6; }
|
button:disabled { cursor: not-allowed; opacity: .6; }
|
||||||
|
button[aria-busy="true"]:disabled { cursor: wait; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
a { color: inherit; }
|
a { color: inherit; }
|
||||||
.reddit-username-link { color: var(--accent); text-decoration: underline; text-decoration-thickness: .08em; text-underline-offset: .16em; }
|
.reddit-username-link { color: var(--accent); text-decoration: underline; text-decoration-thickness: .08em; text-underline-offset: .16em; }
|
||||||
.reddit-username-link:hover { color: #aaf0c4; }
|
.reddit-username-link:hover { color: #aaf0c4; }
|
||||||
@@ -66,10 +77,11 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
|||||||
.status-dot.stopped { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
|
.status-dot.stopped { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
|
||||||
.pill { display: inline-flex; align-items: center; padding: .3rem .55rem; border-radius: 999px; font-size: .75rem; font-weight: 800; }
|
.pill { display: inline-flex; align-items: center; padding: .3rem .55rem; border-radius: 999px; font-size: .75rem; font-weight: 800; }
|
||||||
.pill.neutral { color: #d4dfda; background: #2b3832; }
|
.pill.neutral { color: #d4dfda; background: #2b3832; }
|
||||||
.pill.success { color: #aaf0c4; background: #163a26; }
|
.pill.success { color: var(--success-text); background: var(--success-bg); }
|
||||||
.pill.warning { color: #ffe1a0; background: #42351b; }
|
.pill.warning { color: var(--warning-text); background: #42351b; }
|
||||||
|
.pill.danger { color: var(--danger-text); background: var(--danger-bg); }
|
||||||
.status-note { margin-left: auto; color: var(--muted); font-size: .85rem; }
|
.status-note { margin-left: auto; color: var(--muted); font-size: .85rem; }
|
||||||
.cooldown-banner { display: grid; gap: .25rem; padding: .9rem 1rem; margin-bottom: 1rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .8rem; }
|
.cooldown-banner { display: grid; gap: .25rem; padding: .9rem 1rem; margin-bottom: 1rem; color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); border-radius: .8rem; }
|
||||||
.cooldown-banner span { font-size: .85rem; }
|
.cooldown-banner span { font-size: .85rem; }
|
||||||
.metrics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin-bottom: .75rem; }
|
.metrics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin-bottom: .75rem; }
|
||||||
.metric-card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
.metric-card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
||||||
@@ -86,10 +98,10 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
|||||||
.account-status-details dt { color: var(--muted); font-size: .72rem; }
|
.account-status-details dt { color: var(--muted); font-size: .72rem; }
|
||||||
.account-status-details dd { margin: 0; font-weight: 700; text-transform: capitalize; }
|
.account-status-details dd { margin: 0; font-weight: 700; text-transform: capitalize; }
|
||||||
.account-status-note { color: var(--muted); }
|
.account-status-note { color: var(--muted); }
|
||||||
.account-status-warning { display: grid; gap: .25rem; padding: .65rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .55rem; }
|
.account-status-warning { display: grid; gap: .25rem; padding: .65rem; color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); border-radius: .55rem; }
|
||||||
.account-status-warning span { font-size: .8rem; }
|
.account-status-warning span { font-size: .8rem; }
|
||||||
.account-status-warning code { width: fit-content; padding: .15rem .3rem; color: inherit; background: rgba(0, 0, 0, .25); border-radius: .25rem; }
|
.account-status-warning code { width: fit-content; padding: .15rem .3rem; color: inherit; background: rgba(0, 0, 0, .25); border-radius: .25rem; }
|
||||||
.account-status-warning.is-error { color: #ffd1ce; background: #461b18; border-color: #80352f; }
|
.account-status-warning.is-error { color: var(--danger-text); background: var(--danger-bg); border-color: var(--danger-border); }
|
||||||
.overview-grid { display: grid; gap: .75rem; }
|
.overview-grid { display: grid; gap: .75rem; }
|
||||||
.panel { padding: 1rem; }
|
.panel { padding: 1rem; }
|
||||||
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
||||||
@@ -105,9 +117,9 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
|||||||
.activity-list small { color: var(--muted); }
|
.activity-list small { color: var(--muted); }
|
||||||
.origin-post-trigger { padding: 0; color: inherit; background: transparent; border: 0; font: inherit; font-weight: inherit; text-align: left; text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: .2em; cursor: pointer; }
|
.origin-post-trigger { padding: 0; color: inherit; background: transparent; border: 0; font: inherit; font-weight: inherit; text-align: left; text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: .2em; cursor: pointer; }
|
||||||
.origin-post-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
.origin-post-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||||
.origin-post-backdrop { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .82); }
|
dialog.origin-post-dialog { width: min(34rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); padding: 0; overflow: auto; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .5); }
|
||||||
.origin-post-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .5); }
|
dialog.origin-post-dialog::backdrop { background: rgba(0, 0, 0, .82); }
|
||||||
.origin-post-dialog > img { display: block; width: 100%; max-height: 28rem; object-fit: contain; background: #050907; }
|
.origin-post-dialog img { display: block; width: 100%; max-height: 28rem; object-fit: contain; background: #050907; }
|
||||||
.origin-post-dialog-heading { display: flex; align-items: center; justify-content: space-between; padding: .65rem .8rem; color: var(--muted); border-bottom: 1px solid var(--border); font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
.origin-post-dialog-heading { display: flex; align-items: center; justify-content: space-between; padding: .65rem .8rem; color: var(--muted); border-bottom: 1px solid var(--border); font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
.origin-post-dialog-heading button { width: 2.25rem; height: 2.25rem; padding: 0; color: inherit; background: transparent; border: 0; font-size: 1.6rem; cursor: pointer; }
|
.origin-post-dialog-heading button { width: 2.25rem; height: 2.25rem; padding: 0; color: inherit; background: transparent; border: 0; font-size: 1.6rem; cursor: pointer; }
|
||||||
.origin-post-dialog-copy { display: grid; gap: .7rem; padding: 1rem; }
|
.origin-post-dialog-copy { display: grid; gap: .7rem; padding: 1rem; }
|
||||||
@@ -123,7 +135,8 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
|||||||
.origin-post-image img.is-blurred { filter: blur(18px) brightness(.55); transform: scale(1.12); }
|
.origin-post-image img.is-blurred { filter: blur(18px) brightness(.55); transform: scale(1.12); }
|
||||||
.origin-post-image .origin-post-image-reveal { position: absolute; right: .2rem; bottom: .2rem; min-height: auto; padding: .2rem .3rem; border: 1px solid rgba(255, 255, 255, .45); border-radius: .3rem; color: white; background: rgba(0, 0, 0, .76); font-size: .58rem; font-weight: 800; }
|
.origin-post-image .origin-post-image-reveal { position: absolute; right: .2rem; bottom: .2rem; min-height: auto; padding: .2rem .3rem; border: 1px solid rgba(255, 255, 255, .45); border-radius: .3rem; color: white; background: rgba(0, 0, 0, .76); font-size: .58rem; font-weight: 800; }
|
||||||
.origin-post-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.origin-post-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
.origin-image-preview-backdrop { position: fixed; inset: 0; z-index: 90; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, .92); }
|
dialog.origin-image-preview { width: 100vw; height: 100vh; max-width: 100vw; max-height: 100vh; padding: 1rem; background: transparent; }
|
||||||
|
dialog.origin-image-preview::backdrop { background: rgba(0, 0, 0, .92); }
|
||||||
.origin-image-preview-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
.origin-image-preview-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||||
.origin-image-preview-content img { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
|
.origin-image-preview-content img { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
.origin-image-preview-content button { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; }
|
.origin-image-preview-content button { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; }
|
||||||
@@ -139,12 +152,16 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
|||||||
.error-panel, .panel-loading { display: grid; place-items: center; align-content: center; gap: .75rem; min-height: 14rem; text-align: center; color: var(--muted); }
|
.error-panel, .panel-loading { display: grid; place-items: center; align-content: center; gap: .75rem; min-height: 14rem; text-align: center; color: var(--muted); }
|
||||||
.spinner { width: 1.4rem; height: 1.4rem; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
.spinner { width: 1.4rem; height: 1.4rem; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.dialog-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .72); }
|
dialog.modal { color: inherit; border: 0; background: transparent; }
|
||||||
.dialog-card { width: min(28rem, 100%); padding: 1.4rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .4); }
|
dialog.modal::backdrop { background: rgba(0, 0, 0, .72); }
|
||||||
|
body:has(dialog.modal[open]) { overflow: hidden; }
|
||||||
|
dialog.dialog-card { width: min(28rem, calc(100% - 2rem)); padding: 1.4rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .4); }
|
||||||
.dialog-card p { color: var(--muted); }
|
.dialog-card p { color: var(--muted); }
|
||||||
|
.dialog-error { padding: .65rem .8rem; color: var(--danger-text); background: var(--danger-bg); border: 1px solid var(--danger-border); border-radius: .55rem; }
|
||||||
.dialog-actions { justify-content: flex-end; margin-top: 1.5rem; }
|
.dialog-actions { justify-content: flex-end; margin-top: 1.5rem; }
|
||||||
.toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; }
|
.toast { position: fixed; right: 1rem; bottom: 1rem; z-index: 110; display: flex; align-items: baseline; gap: .6rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; }
|
||||||
.toast.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
.toast.error { color: var(--danger-text); background: var(--danger-bg); border: 1px solid var(--danger-border); }
|
||||||
|
.toast button { min-height: auto; padding: 0; color: inherit; background: transparent; border: 0; font-weight: 800; text-decoration: underline; }
|
||||||
|
|
||||||
.account-owner-badge {
|
.account-owner-badge {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
|||||||
Generated
+1
@@ -2920,6 +2920,7 @@
|
|||||||
"version": "13.0.3",
|
"version": "13.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-addon-api": "^8.0.0"
|
"node-addon-api": "^8.0.0"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { HttpResponse, http } from 'msw';
|
import { HttpResponse, http } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
@@ -339,7 +339,7 @@ describe('dashboard foundation', () => {
|
|||||||
expect(within(dialog).getByRole('img', { name: 'Source post: The source post title' })).toHaveAttribute(
|
expect(within(dialog).getByRole('img', { name: 'Source post: The source post title' })).toHaveAttribute(
|
||||||
'src', 'https://i.redd.it/source.jpg',
|
'src', 'https://i.redd.it/source.jpg',
|
||||||
);
|
);
|
||||||
expect(within(dialog).getByRole('link', { name: 'Open Reddit post' })).toHaveAttribute(
|
expect(within(dialog).getByRole('link', { name: /Open Reddit post/ })).toHaveAttribute(
|
||||||
'href', 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
|
'href', 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -401,4 +401,59 @@ describe('dashboard foundation', () => {
|
|||||||
expect(await screen.findByText('Running', { selector: '[data-bot-status]' })).toBeInTheDocument();
|
expect(await screen.findByText('Running', { selector: '[data-bot-status]' })).toBeInTheDocument();
|
||||||
expect(startRequests).toBe(1);
|
expect(startRequests).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a start failure inside the confirmation dialog instead of a stray toast', async () => {
|
||||||
|
installSuccessfulApi();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/start', () => HttpResponse.json({ error: 'Browser runtime is not ready' }, { status: 503 })),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDashboard();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Start bot' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Start bot?' });
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Confirm start' }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Browser runtime is not ready');
|
||||||
|
// The dialog stays open so the operator can retry or cancel.
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Start bot?' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips the sending window from cooling down to ready without a refetch', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
let timerRequests = 0;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/stats', () => HttpResponse.json(createStats())),
|
||||||
|
http.get('/api/timers', () => {
|
||||||
|
timerRequests += 1;
|
||||||
|
return HttpResponse.json({
|
||||||
|
nextMessageAvailableAt: startedAt + 5_000,
|
||||||
|
hourlyResetAt: startedAt + 3_600_000,
|
||||||
|
isHourlyLimitReached: false,
|
||||||
|
messagesRemaining: 6,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/messages', () => HttpResponse.json([])),
|
||||||
|
);
|
||||||
|
renderDashboard();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Cooling down')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/^Ready in /)).toBeInTheDocument();
|
||||||
|
const requestsAfterLoad = timerRequests;
|
||||||
|
|
||||||
|
act(() => { vi.advanceTimersByTime(6_000); });
|
||||||
|
|
||||||
|
expect(screen.getByText('Ready')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Available now')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Cooling down')).not.toBeInTheDocument();
|
||||||
|
expect(timerRequests).toBe(requestsAfterLoad);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||||
|
import { HttpResponse, http } from 'msw';
|
||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { AttentionPage } from '../../apps/dashboard/src/features/attention/attention-page.js';
|
||||||
|
|
||||||
|
const MINUTE_MS = 60_000;
|
||||||
|
|
||||||
|
function attentionResponse(items: unknown[]) {
|
||||||
|
return { generatedAt: Date.now() - MINUTE_MS, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
const criticalItem = {
|
||||||
|
id: 'item-critical',
|
||||||
|
kind: 'human-required',
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'Human handoff required',
|
||||||
|
detail: 'Automation stopped and needs an operator reply.',
|
||||||
|
occurredAt: Date.now() - (14 * MINUTE_MS),
|
||||||
|
conversationId: 'room-alice',
|
||||||
|
username: 'alice',
|
||||||
|
};
|
||||||
|
|
||||||
|
const warningItem = {
|
||||||
|
id: 'item-warning',
|
||||||
|
kind: 'invitation-cooldown',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Invitation cooldown active',
|
||||||
|
detail: 'Reddit is rate limiting new chat invitations.',
|
||||||
|
occurredAt: Date.now() - (2 * 60 * MINUTE_MS),
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = setupServer();
|
||||||
|
|
||||||
|
function renderAttention() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AttentionPage />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
return queryClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
describe('AttentionPage', () => {
|
||||||
|
it('labels critical items and the critical count with the danger pill', async () => {
|
||||||
|
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem, warningItem]))));
|
||||||
|
renderAttention();
|
||||||
|
|
||||||
|
const items = await screen.findByRole('list', { name: 'Attention items' });
|
||||||
|
const [first, second] = within(items).getAllByRole('listitem');
|
||||||
|
|
||||||
|
expect(within(first).getByText('human required')).toHaveClass('pill', 'danger');
|
||||||
|
expect(within(second).getByText('invitation cooldown')).toHaveClass('pill', 'warning');
|
||||||
|
expect(screen.getByText('1 critical')).toHaveClass('pill', 'danger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows relative times with exact timestamps as titles', async () => {
|
||||||
|
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem]))));
|
||||||
|
renderAttention();
|
||||||
|
|
||||||
|
const time = await screen.findByText('14m ago');
|
||||||
|
expect(time.tagName).toBe('TIME');
|
||||||
|
expect(time).toHaveAttribute('dateTime', new Date(criticalItem.occurredAt).toISOString());
|
||||||
|
expect(time).toHaveAttribute('title');
|
||||||
|
expect(screen.getByText(/Auto-refreshes every 30s/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links conversation items and gives unlinked items a place to act', async () => {
|
||||||
|
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem, warningItem]))));
|
||||||
|
renderAttention();
|
||||||
|
|
||||||
|
expect(await screen.findByRole('link', { name: 'Open conversation' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/conversations/room-alice',
|
||||||
|
);
|
||||||
|
expect(screen.getByText('No linked conversation — check Operations.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Reddit is rate limiting new chat invitations.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the dedicated attention dot for the open-items summary', async () => {
|
||||||
|
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem]))));
|
||||||
|
renderAttention();
|
||||||
|
|
||||||
|
await screen.findByText('Open items');
|
||||||
|
const strip = screen.getByRole('region', { name: 'Attention totals' });
|
||||||
|
const dot = strip.querySelector('.attention-status-dot');
|
||||||
|
expect(dot).toHaveClass('is-open');
|
||||||
|
expect(dot).not.toHaveClass('running');
|
||||||
|
expect(dot).not.toHaveClass('stopped');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { HttpResponse, http } from 'msw';
|
import { HttpResponse, http } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
|
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
|
||||||
|
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
|
||||||
|
|
||||||
interface ChatFixtureOptions {
|
interface ChatFixtureOptions {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -81,9 +83,11 @@ function renderConversations() {
|
|||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
});
|
});
|
||||||
render(
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ConversationsPage />
|
<ConversationsPage />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
return queryClient;
|
return queryClient;
|
||||||
}
|
}
|
||||||
@@ -92,6 +96,7 @@ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
server.resetHandlers();
|
server.resetHandlers();
|
||||||
|
resetComposerDrafts();
|
||||||
});
|
});
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close());
|
||||||
|
|
||||||
@@ -152,8 +157,7 @@ describe('ConversationsPage', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText('2h ago')).toBeInTheDocument();
|
expect(screen.getByText('2h ago')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred');
|
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred');
|
||||||
expect(screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' })).toBeDisabled();
|
await user.click(screen.getByRole('button', { name: 'Reveal source image: Looking for a chat' }));
|
||||||
await user.click(screen.getByRole('button', { name: 'Reveal original image' }));
|
|
||||||
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred');
|
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred');
|
||||||
const previewTrigger = screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' });
|
const previewTrigger = screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' });
|
||||||
await user.click(previewTrigger);
|
await user.click(previewTrigger);
|
||||||
@@ -321,6 +325,7 @@ describe('ConversationsPage', () => {
|
|||||||
);
|
);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderConversations();
|
renderConversations();
|
||||||
|
await user.click(screen.getByText('Processed user lookup'));
|
||||||
await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice');
|
await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice');
|
||||||
await user.click(screen.getByRole('button', { name: 'Look up user' }));
|
await user.click(screen.getByRole('button', { name: 'Look up user' }));
|
||||||
expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post');
|
expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post');
|
||||||
@@ -438,4 +443,124 @@ describe('ConversationsPage', () => {
|
|||||||
expect(message).toHaveValue('');
|
expect(message).toHaveValue('');
|
||||||
expect(startRequests).toBe(2);
|
expect(startRequests).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('explains how to enable the start-conversation form when no accounts are running', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/conversations', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/accounts', () => HttpResponse.json([])),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderConversations();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'New conversation' }));
|
||||||
|
expect(await screen.findByText(/No running accounts/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings');
|
||||||
|
expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the unread count and marks unread rows with state and time in the label', async () => {
|
||||||
|
installConversationList();
|
||||||
|
renderConversations();
|
||||||
|
|
||||||
|
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
|
||||||
|
expect(screen.getByText('1 unread')).toBeInTheDocument();
|
||||||
|
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, .+ ago/);
|
||||||
|
expect(aliceButton.closest('li')).toHaveClass('is-unread');
|
||||||
|
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers a jump-to-latest control when the operator scrolls up in the history', async () => {
|
||||||
|
installConversationList();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderConversations();
|
||||||
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||||
|
|
||||||
|
const history = await screen.findByRole('list', { name: 'Message history' });
|
||||||
|
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
Object.defineProperty(history, 'scrollHeight', { configurable: true, value: 1000 });
|
||||||
|
Object.defineProperty(history, 'clientHeight', { configurable: true, value: 300 });
|
||||||
|
history.scrollTop = 100;
|
||||||
|
fireEvent.scroll(history);
|
||||||
|
|
||||||
|
const jumpButton = await screen.findByRole('button', { name: 'Jump to latest' });
|
||||||
|
await user.click(jumpButton);
|
||||||
|
expect(history.scrollTop).toBe(1000);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an unsent draft when switching away from and back to a conversation', async () => {
|
||||||
|
installConversationList();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderConversations();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||||
|
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
|
||||||
|
await user.type(composer, 'Draft in progress');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Close conversation' }));
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Reply to u/alice' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
|
||||||
|
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Draft in progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the operator undo a suggestion that replaced a typed draft', async () => {
|
||||||
|
installConversationList();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/conversations/room-alice/suggest-reply', () =>
|
||||||
|
HttpResponse.json({ suggestedReply: 'A generated suggestion' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderConversations();
|
||||||
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||||
|
|
||||||
|
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
|
||||||
|
await user.type(composer, 'My own words');
|
||||||
|
expect(screen.getByText('12 / 10000')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
|
||||||
|
await waitFor(() => expect(composer).toHaveValue('A generated suggestion'));
|
||||||
|
expect(screen.getByText('The suggestion replaced your draft.')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Undo' }));
|
||||||
|
expect(composer).toHaveValue('My own words');
|
||||||
|
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the confirmation image blurred when saved images were re-blurred before review', async () => {
|
||||||
|
const asset = {
|
||||||
|
id: '22222222-2222-4222-8222-222222222222', tags: ['selfie'], safety: 'sfw',
|
||||||
|
classificationStatus: 'ready', description: 'A selfie', confidence: 0.95,
|
||||||
|
fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000,
|
||||||
|
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
||||||
|
sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg',
|
||||||
|
};
|
||||||
|
server.use(
|
||||||
|
http.get('/api/conversations', () => HttpResponse.json([alice])),
|
||||||
|
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
|
||||||
|
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({
|
||||||
|
requestKind: 'selfie', assets: [asset], reason: null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderConversations();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||||
|
await user.click(await screen.findByText('Share a saved image'));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Reveal images to select one.')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Reveal saved images' }));
|
||||||
|
expect(screen.queryByText('Reveal images to select one.')).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Blur saved images' }));
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Review image share' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' });
|
||||||
|
const dialogImage = within(dialog).getByRole('img', { name: 'Saved image tagged selfie' });
|
||||||
|
expect(dialogImage).toHaveClass('is-blurred');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Reveal image' }));
|
||||||
|
expect(dialogImage).not.toHaveClass('is-blurred');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { HttpResponse, http } from 'msw';
|
import { HttpResponse, http } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
@@ -17,6 +17,12 @@ const asset = {
|
|||||||
sendCount: 0, lastSentAt: null,
|
sendCount: 0, lastSentAt: null,
|
||||||
urlPath: '/library-media/portrait.jpg',
|
urlPath: '/library-media/portrait.jpg',
|
||||||
};
|
};
|
||||||
|
const secondAsset = {
|
||||||
|
...asset,
|
||||||
|
id: '029fe789-1b99-7ce1-8402-b3cbc32a445b',
|
||||||
|
fileName: 'second.jpg',
|
||||||
|
urlPath: '/library-media/second.jpg',
|
||||||
|
};
|
||||||
const server = setupServer();
|
const server = setupServer();
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
@@ -24,6 +30,13 @@ function renderPage() {
|
|||||||
return render(<QueryClientProvider client={client}><MediaLibraryPage /></QueryClientProvider>);
|
return render(<QueryClientProvider client={client}><MediaLibraryPage /></QueryClientProvider>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cardFor(fileName: string): HTMLElement {
|
||||||
|
const title = screen.getByText(fileName, { selector: 'strong' });
|
||||||
|
const card = title.closest('article');
|
||||||
|
if (!card) throw new Error(`No card for ${fileName}`);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||||
afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); });
|
afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); });
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close());
|
||||||
@@ -43,14 +56,101 @@ describe('exchange library page', () => {
|
|||||||
|
|
||||||
const image = await screen.findByRole('img', { name: /Library image tagged selfie/i });
|
const image = await screen.findByRole('img', { name: /Library image tagged selfie/i });
|
||||||
expect(image).toHaveClass('is-blurred');
|
expect(image).toHaveClass('is-blurred');
|
||||||
await user.click(screen.getByRole('button', { name: 'Reveal' }));
|
const revealToggle = screen.getByRole('button', { name: 'Reveal portrait.jpg' });
|
||||||
|
expect(revealToggle).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await user.click(revealToggle);
|
||||||
expect(image).not.toHaveClass('is-blurred');
|
expect(image).not.toHaveClass('is-blurred');
|
||||||
|
expect(screen.getByRole('button', { name: 'Blur portrait.jpg' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
await user.click(screen.getByRole('checkbox', { name: 'topless' }));
|
await user.click(screen.getByRole('checkbox', { name: 'topless' }));
|
||||||
|
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
|
||||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Safety' }), 'nsfw');
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Safety' }), 'nsfw');
|
||||||
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||||
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'topless'], safety: 'nsfw', enabled: true }));
|
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'topless'], safety: 'nsfw', enabled: true }));
|
||||||
|
|
||||||
|
// Transient save feedback next to the Save button.
|
||||||
|
const saved = await screen.findByText('Saved');
|
||||||
|
expect(saved).toHaveAttribute('role', 'status');
|
||||||
|
await waitFor(() => expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens a revealed library image in the enlarging preview dialog', async () => {
|
||||||
|
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByRole('img', { name: /Library image tagged selfie/i });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Reveal library image portrait.jpg' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'View portrait.jpg larger' }));
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Preview of portrait.jpg' });
|
||||||
|
expect(within(dialog).getByRole('img')).toHaveAttribute('src', '/library-media/portrait.jpg');
|
||||||
|
// Single-item mode: no prev/next controls.
|
||||||
|
expect(within(dialog).queryByRole('button', { name: 'Next media' })).not.toBeInTheDocument();
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Close media preview' }));
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a touched form when a refetch replaces the assets', async () => {
|
||||||
|
let update: unknown;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/media-library', () => HttpResponse.json([asset, secondAsset])),
|
||||||
|
http.patch('/api/media-library/:id', async ({ request }) => {
|
||||||
|
update = await request.json();
|
||||||
|
return HttpResponse.json({ ...asset, ...(update as object) });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('portrait.jpg');
|
||||||
|
const secondCard = cardFor('second.jpg');
|
||||||
|
|
||||||
|
// Touch the second card's form without saving it.
|
||||||
|
await user.click(within(secondCard).getByRole('checkbox', { name: 'topless' }));
|
||||||
|
expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Saving the first card invalidates and refetches the whole list.
|
||||||
|
const firstCard = cardFor('portrait.jpg');
|
||||||
|
await user.click(within(firstCard).getByRole('checkbox', { name: 'chest' }));
|
||||||
|
await user.click(within(firstCard).getByRole('button', { name: 'Save changes' }));
|
||||||
|
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'chest'], safety: 'sfw', enabled: true }));
|
||||||
|
await within(firstCard).findByText('Saved');
|
||||||
|
|
||||||
|
// The refetch must not clobber the second card's unsaved edit.
|
||||||
|
expect(within(secondCard).getByRole('checkbox', { name: 'topless' })).toBeChecked();
|
||||||
|
expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains why saving is disabled when no tags are selected', async () => {
|
||||||
|
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('portrait.jpg');
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'selfie' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
|
||||||
|
expect(screen.getByText('Select at least one tag to save')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arms deletion away from the Delete position and disarms it after five seconds', async () => {
|
||||||
|
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('portrait.jpg');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||||
|
const actions = screen.getByRole('button', { name: 'Confirm delete' }).parentElement!;
|
||||||
|
const buttons = within(actions).getAllByRole('button');
|
||||||
|
// Cancel occupies the slot Delete was in; Confirm sits after it.
|
||||||
|
expect(buttons.map((button) => button.textContent)).toEqual(['Save changes', 'Cancel', 'Confirm delete']);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(),
|
||||||
|
{ timeout: 7_000 },
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
it('requires consent in the UI and uploads the selected image for classification', async () => {
|
it('requires consent in the UI and uploads the selected image for classification', async () => {
|
||||||
let consentHeader: string | null = null;
|
let consentHeader: string | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
@@ -65,12 +165,58 @@ describe('exchange library page', () => {
|
|||||||
await screen.findByText('No exchange images yet.');
|
await screen.findByText('No exchange images yet.');
|
||||||
const upload = screen.getByRole('button', { name: 'Upload and classify' });
|
const upload = screen.getByRole('button', { name: 'Upload and classify' });
|
||||||
expect(upload).toBeDisabled();
|
expect(upload).toBeDisabled();
|
||||||
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
|
const input = screen.getByLabelText(/Choose image/) as HTMLInputElement;
|
||||||
|
await user.upload(input, new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
|
||||||
expect(upload).toBeDisabled();
|
expect(upload).toBeDisabled();
|
||||||
await user.click(screen.getByRole('checkbox', { name: /I confirm every depicted adult consented/i }));
|
await user.click(screen.getByRole('checkbox', { name: /I confirm every depicted adult consented/i }));
|
||||||
expect(upload).toBeEnabled();
|
expect(upload).toBeEnabled();
|
||||||
await user.click(upload);
|
await user.click(upload);
|
||||||
await screen.findByText('Image added. Review its tags before relying on it.');
|
await screen.findByText('Image added. Review its tags before relying on it.');
|
||||||
expect(consentHeader).toBe('true');
|
expect(consentHeader).toBe('true');
|
||||||
|
|
||||||
|
// The input resets so the same file can be picked again.
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
expect(screen.getByText('No image selected')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-image and oversized files before uploading', async () => {
|
||||||
|
server.use(http.get('/api/media-library', () => HttpResponse.json([])));
|
||||||
|
const user = userEvent.setup({ applyAccept: false });
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('No exchange images yet.');
|
||||||
|
const input = screen.getByLabelText(/Choose image/) as HTMLInputElement;
|
||||||
|
|
||||||
|
await user.upload(input, new File(['nope'], 'notes.txt', { type: 'text/plain' }));
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Only image files can be uploaded.');
|
||||||
|
expect(screen.getByRole('button', { name: 'Upload and classify' })).toBeDisabled();
|
||||||
|
|
||||||
|
const oversized = new File(['big'], 'huge.jpg', { type: 'image/jpeg' });
|
||||||
|
Object.defineProperty(oversized, 'size', { value: 16 * 1024 * 1024 });
|
||||||
|
await user.upload(input, oversized);
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Images must be 15 MB or smaller.');
|
||||||
|
expect(screen.getByText('No image selected')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a thumbnail preview of the picked image when object URLs are available', async () => {
|
||||||
|
const objectUrls: string[] = [];
|
||||||
|
const globalUrl = URL as unknown as Record<string, unknown>;
|
||||||
|
globalUrl.createObjectURL = vi.fn(() => {
|
||||||
|
const url = `blob:preview-${objectUrls.length}`;
|
||||||
|
objectUrls.push(url);
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
globalUrl.revokeObjectURL = vi.fn();
|
||||||
|
try {
|
||||||
|
server.use(http.get('/api/media-library', () => HttpResponse.json([])));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('No exchange images yet.');
|
||||||
|
|
||||||
|
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
|
||||||
|
expect(screen.getByRole('img', { name: 'Preview of portrait.jpg' })).toHaveAttribute('src', 'blob:preview-0');
|
||||||
|
} finally {
|
||||||
|
delete globalUrl.createObjectURL;
|
||||||
|
delete globalUrl.revokeObjectURL;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+138
-11
@@ -30,6 +30,15 @@ const mediaItems = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function altName(kind: 'Image' | 'GIF', sender: string, timestamp: number): string {
|
||||||
|
const date = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(timestamp);
|
||||||
|
return `${kind} from u/${sender}, ${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliceAlt = altName('Image', 'alice', mediaItems[0].timestamp);
|
||||||
|
const charlieAlt = altName('GIF', 'charlie', mediaItems[1].timestamp);
|
||||||
|
const daveAlt = altName('Image', 'dave', mediaItems[2].timestamp);
|
||||||
|
|
||||||
const idleStatus = {
|
const idleStatus = {
|
||||||
isSyncing: false,
|
isSyncing: false,
|
||||||
lastSyncAt: now - 120_000,
|
lastSyncAt: now - 120_000,
|
||||||
@@ -62,6 +71,11 @@ function renderMedia() {
|
|||||||
return { ...view, queryClient };
|
return { ...view, queryClient };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The card's footer toggle carries aria-pressed; the image button does not. */
|
||||||
|
function revealToggle(sender: string, pressed: boolean) {
|
||||||
|
return screen.getByRole('button', { name: `Reveal media from ${sender}`, pressed });
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -91,8 +105,8 @@ describe('media page', () => {
|
|||||||
await waitFor(() => expect(resolveMedia).toBeTypeOf('function'));
|
await waitFor(() => expect(resolveMedia).toBeTypeOf('function'));
|
||||||
resolveMedia?.(HttpResponse.json(mediaItems));
|
resolveMedia?.(HttpResponse.json(mediaItems));
|
||||||
|
|
||||||
const aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
|
const aliceImage = await screen.findByRole('img', { name: aliceAlt });
|
||||||
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' });
|
const charlieImage = screen.getByRole('img', { name: charlieAlt });
|
||||||
for (const profileLink of screen.getAllByRole('link', { name: 'alice' })) {
|
for (const profileLink of screen.getAllByRole('link', { name: 'alice' })) {
|
||||||
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
|
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
|
||||||
}
|
}
|
||||||
@@ -100,17 +114,44 @@ describe('media page', () => {
|
|||||||
expect(charlieImage).toHaveClass('is-blurred');
|
expect(charlieImage).toHaveClass('is-blurred');
|
||||||
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
|
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Reveal media from alice' }));
|
await user.click(revealToggle('alice', false));
|
||||||
expect(aliceImage).not.toHaveClass('is-blurred');
|
expect(aliceImage).not.toHaveClass('is-blurred');
|
||||||
expect(charlieImage).toHaveClass('is-blurred');
|
expect(charlieImage).toHaveClass('is-blurred');
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
|
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
|
||||||
expect(charlieImage).not.toHaveClass('is-blurred');
|
expect(charlieImage).not.toHaveClass('is-blurred');
|
||||||
|
|
||||||
|
// Turning reveal-all back off keeps the earlier individual reveal intact.
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Blur all media' }));
|
||||||
|
expect(charlieImage).toHaveClass('is-blurred');
|
||||||
|
expect(aliceImage).not.toHaveClass('is-blurred');
|
||||||
|
|
||||||
firstView.unmount();
|
firstView.unmount();
|
||||||
installMediaApi();
|
installMediaApi();
|
||||||
renderMedia();
|
renderMedia();
|
||||||
expect(await screen.findByRole('img', { name: 'Media from alice' })).toHaveClass('is-blurred');
|
expect(await screen.findByRole('img', { name: aliceAlt })).toHaveClass('is-blurred');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals blurred media when its preview surface is clicked', async () => {
|
||||||
|
installMediaApi();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderMedia();
|
||||||
|
|
||||||
|
const aliceImage = await screen.findByRole('img', { name: aliceAlt });
|
||||||
|
expect(aliceImage).toHaveClass('is-blurred');
|
||||||
|
|
||||||
|
// The image surface itself acts as a reveal control while blurred.
|
||||||
|
const imageButton = screen
|
||||||
|
.getAllByRole('button', { name: 'Reveal media from alice' })
|
||||||
|
.find((button) => button.classList.contains('media-preview-button'));
|
||||||
|
expect(imageButton).toBeDefined();
|
||||||
|
expect(imageButton).toBeEnabled();
|
||||||
|
await user.click(imageButton!);
|
||||||
|
expect(aliceImage).not.toHaveClass('is-blurred');
|
||||||
|
|
||||||
|
// Once revealed, the same surface opens the full-size preview instead.
|
||||||
|
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Media from alice' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens revealed media in a dismissible full-size preview', async () => {
|
it('opens revealed media in a dismissible full-size preview', async () => {
|
||||||
@@ -118,16 +159,43 @@ describe('media page', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderMedia();
|
renderMedia();
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Reveal media from alice' }));
|
await screen.findByRole('img', { name: aliceAlt });
|
||||||
|
await user.click(revealToggle('alice', false));
|
||||||
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
|
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog', { name: 'Media from alice' });
|
const dialog = screen.getByRole('dialog', { name: 'Media from alice' });
|
||||||
expect(within(dialog).getByRole('img', { name: 'Media from alice' })).toHaveAttribute('src', '/media/one.jpg');
|
expect(within(dialog).getByRole('img', { name: aliceAlt })).toHaveAttribute('src', '/media/one.jpg');
|
||||||
|
|
||||||
await user.keyboard('{Escape}');
|
await user.keyboard('{Escape}');
|
||||||
expect(screen.queryByRole('dialog', { name: 'Media from alice' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: 'Media from alice' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('navigates the preview across the visible items with buttons and arrow keys', async () => {
|
||||||
|
installMediaApi();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderMedia();
|
||||||
|
|
||||||
|
await screen.findByRole('img', { name: aliceAlt });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
|
||||||
|
|
||||||
|
let dialog = screen.getByRole('dialog', { name: 'Media from alice' });
|
||||||
|
expect(within(dialog).getByRole('button', { name: 'Previous media' })).toBeDisabled();
|
||||||
|
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Next media' }));
|
||||||
|
dialog = screen.getByRole('dialog', { name: 'Media from charlie' });
|
||||||
|
expect(within(dialog).getByRole('img', { name: charlieAlt })).toHaveAttribute('src', '/media/two.gif');
|
||||||
|
expect(within(dialog).getByText('u/charlie')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.keyboard('{ArrowRight}');
|
||||||
|
dialog = screen.getByRole('dialog', { name: 'Media from dave' });
|
||||||
|
expect(within(dialog).getByRole('button', { name: 'Next media' })).toBeDisabled();
|
||||||
|
|
||||||
|
await user.keyboard('{ArrowLeft}');
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Media from charlie' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('links media cards to their account-owned conversations', async () => {
|
it('links media cards to their account-owned conversations', async () => {
|
||||||
installMediaApi();
|
installMediaApi();
|
||||||
renderMedia();
|
renderMedia();
|
||||||
@@ -149,11 +217,11 @@ describe('media page', () => {
|
|||||||
|
|
||||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
|
||||||
expect(screen.getAllByRole('img')).toHaveLength(1);
|
expect(screen.getAllByRole('img')).toHaveLength(1);
|
||||||
expect(screen.getByRole('img', { name: 'Media from alice' })).toBeInTheDocument();
|
expect(screen.getByRole('img', { name: aliceAlt })).toBeInTheDocument();
|
||||||
|
|
||||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'all');
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'all');
|
||||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
|
||||||
expect(screen.getByRole('img', { name: 'Media from charlie' })).toBeInTheDocument();
|
expect(screen.getByRole('img', { name: charlieAlt })).toBeInTheDocument();
|
||||||
|
|
||||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'all');
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'all');
|
||||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'gif');
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'gif');
|
||||||
@@ -167,6 +235,49 @@ describe('media page', () => {
|
|||||||
expect(screen.getByText('No media matches these filters.')).toBeInTheDocument();
|
expect(screen.getByText('No media matches these filters.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a result count and clears filters from both the panel and the empty state', async () => {
|
||||||
|
installMediaApi();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderMedia();
|
||||||
|
|
||||||
|
await screen.findByRole('img', { name: aliceAlt });
|
||||||
|
expect(screen.getByText('Showing 3 of 3 items')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
|
||||||
|
expect(screen.getByText('Showing 1 of 3 items')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
|
||||||
|
expect(screen.getAllByRole('img')).toHaveLength(3);
|
||||||
|
expect(screen.getByRole('combobox', { name: 'Conversation' })).toHaveValue('all');
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'image');
|
||||||
|
const emptyState = screen.getByText('No media matches these filters.').closest('section');
|
||||||
|
expect(emptyState).not.toBeNull();
|
||||||
|
await user.click(within(emptyState!).getByRole('button', { name: 'Clear filters' }));
|
||||||
|
expect(screen.getAllByRole('img')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates large galleries behind a Load more control', async () => {
|
||||||
|
const manyItems = Array.from({ length: 60 }, (_, index) => ({
|
||||||
|
id: `item-${index}`, chatUsername: 'alice', chatRoomId: `room-${index}`, sender: 'alice',
|
||||||
|
timestamp: now - index * 60_000, originalUrl: `https://media.example/${index}.jpg`,
|
||||||
|
fileName: `${index}.jpg`, relativePath: `${index}.jpg`, createdAt: now - index * 60_000,
|
||||||
|
urlPath: `/media/${index}.jpg`,
|
||||||
|
}));
|
||||||
|
installMediaApi(manyItems);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderMedia();
|
||||||
|
|
||||||
|
await screen.findByText('Showing 48 of 60 items');
|
||||||
|
expect(screen.getAllByRole('img')).toHaveLength(48);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Load more (12 remaining)' }));
|
||||||
|
expect(screen.getAllByRole('img')).toHaveLength(60);
|
||||||
|
expect(screen.getByText('Showing 60 of 60 items')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /Load more/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('orders media by received time', async () => {
|
it('orders media by received time', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/media', () => HttpResponse.json([mediaItems[2], mediaItems[0], mediaItems[1]])),
|
http.get('/api/media', () => HttpResponse.json([mediaItems[2], mediaItems[0], mediaItems[1]])),
|
||||||
@@ -175,9 +286,9 @@ describe('media page', () => {
|
|||||||
renderMedia();
|
renderMedia();
|
||||||
|
|
||||||
expect((await screen.findAllByRole('img')).map((item) => item.getAttribute('alt'))).toEqual([
|
expect((await screen.findAllByRole('img')).map((item) => item.getAttribute('alt'))).toEqual([
|
||||||
'Media from alice',
|
aliceAlt,
|
||||||
'Media from charlie',
|
charlieAlt,
|
||||||
'Media from dave',
|
daveAlt,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,6 +341,22 @@ describe('media page', () => {
|
|||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Sync already in progress');
|
expect(await screen.findByRole('alert')).toHaveTextContent('Sync already in progress');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('auto-dismisses the sync success notice after five seconds', async () => {
|
||||||
|
installMediaApi();
|
||||||
|
server.use(http.post('/api/media/sync', () => HttpResponse.json({ success: true })));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderMedia();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Sync new media' }));
|
||||||
|
const notice = await screen.findByText('Media sync completed.');
|
||||||
|
expect(notice).toHaveAttribute('role', 'status');
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => expect(screen.queryByText('Media sync completed.')).not.toBeInTheDocument(),
|
||||||
|
{ timeout: 7_000 },
|
||||||
|
);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
it('refreshes shared sync status as soon as a long-running mutation starts', async () => {
|
it('refreshes shared sync status as soon as a long-running mutation starts', async () => {
|
||||||
let statusRequests = 0;
|
let statusRequests = 0;
|
||||||
let resolveSync: ((response: Response) => void) | undefined;
|
let resolveSync: ((response: Response) => void) | undefined;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { HttpResponse, http } from 'msw';
|
import { HttpResponse, http } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
import type { PropsWithChildren, ReactElement } from 'react';
|
import type { PropsWithChildren, ReactElement } from 'react';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js';
|
import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js';
|
||||||
import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js';
|
import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js';
|
||||||
|
|
||||||
@@ -97,27 +97,102 @@ describe('PerformancePage', () => {
|
|||||||
renderPage(<PerformancePage />);
|
renderPage(<PerformancePage />);
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: 'AI cost and reliability' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: 'AI cost and reliability' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('table', { name: 'AI usage by model' })).toHaveTextContent('model-a');
|
expect(screen.getByRole('table', { name: 'AI usage by model' })).toHaveTextContent('model-a');
|
||||||
expect(screen.getByLabelText('AI usage totals')).toHaveTextContent('$0.0123');
|
expect(screen.getByLabelText('AI usage totals')).toHaveTextContent('$0.0123');
|
||||||
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
|
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
|
||||||
const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' });
|
const subredditTable = await screen.findByRole('table', { name: 'Subreddit performance' });
|
||||||
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
|
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
|
||||||
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
|
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
|
||||||
expect(within(subredditTable).getAllByText('Low confidence')).toHaveLength(2);
|
expect(within(subredditTable).getAllByText('Low confidence')).toHaveLength(2);
|
||||||
expect(within(subredditTable).getByRole('columnheader', { name: 'Images' })).toBeInTheDocument();
|
expect(within(subredditTable).getByRole('columnheader', { name: 'Images' })).toBeInTheDocument();
|
||||||
expect(within(subredditTable).getByText('15%')).toBeInTheDocument();
|
expect(within(subredditTable).getByText('15%')).toBeInTheDocument();
|
||||||
expect(within(subredditTable).getByText('9 pending')).toBeInTheDocument();
|
expect(within(subredditTable).getByText('9 pending')).toBeInTheDocument();
|
||||||
const strategyTable = screen.getByRole('table', { name: 'Strategy performance' });
|
const strategyTable = await screen.findByRole('table', { name: 'Strategy performance' });
|
||||||
expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument();
|
expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument();
|
||||||
expect(within(strategyTable).getAllByText('Configured')).toHaveLength(2);
|
expect(within(strategyTable).getAllByText('Configured')).toHaveLength(2);
|
||||||
expect(within(strategyTable).getByText('7 pending')).toBeInTheDocument();
|
expect(within(strategyTable).getByText('7 pending')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
|
expect(await screen.findByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
|
||||||
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
|
expect(await screen.findByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
|
||||||
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!');
|
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!');
|
||||||
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('Image received');
|
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('Image received');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders each panel independently and retries only the failed panel', async () => {
|
||||||
|
let funnelRequests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/analytics/by-subreddit', () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30, totalImages: 6, imageRate: 15 },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
http.get('/api/analytics/by-strategy', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/analytics/trend', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/successful', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/analytics/funnel', () => {
|
||||||
|
funnelRequests += 1;
|
||||||
|
if (funnelRequests === 1) return HttpResponse.json({ error: 'Funnel unavailable right now' }, { status: 503 });
|
||||||
|
return HttpResponse.json({
|
||||||
|
responseWindowHours: 48, processed: 100, sent: 40, pending: 5, mature: 35,
|
||||||
|
responded: 12, engaged: 4, declined: 2, boundary: 1, upsell: 3, images: 2,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/analytics/ai-usage', () => HttpResponse.json({
|
||||||
|
days: 30, requests: 0, successes: 0, refusals: 0, errors: 0,
|
||||||
|
promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0, averageLatencyMs: 0, models: [],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage(<PerformancePage />);
|
||||||
|
|
||||||
|
// The failed funnel query renders its own inline error…
|
||||||
|
const funnelError = await screen.findByRole('alert');
|
||||||
|
expect(funnelError).toHaveTextContent('Funnel unavailable right now');
|
||||||
|
// …while sibling panels still render their data and empty states.
|
||||||
|
expect(await screen.findByRole('table', { name: 'Subreddit performance' })).toHaveTextContent('askuk');
|
||||||
|
expect(screen.getAllByText('No sends recorded yet.').length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
await user.click(within(funnelError).getByRole('button', { name: 'Retry outcome funnel' }));
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
|
||||||
|
expect(funnelRequests).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the trend and AI usage range with the day toggle', async () => {
|
||||||
|
const trendDays: Array<string | null> = [];
|
||||||
|
const usageDays: Array<string | null> = [];
|
||||||
|
server.use(
|
||||||
|
http.get('/api/analytics/by-subreddit', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/analytics/by-strategy', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/successful', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/analytics/funnel', () => HttpResponse.json({
|
||||||
|
responseWindowHours: 48, processed: 0, sent: 0, pending: 0, mature: 0,
|
||||||
|
responded: 0, engaged: 0, declined: 0, boundary: 0, upsell: 0, images: 0,
|
||||||
|
})),
|
||||||
|
http.get('/api/analytics/trend', ({ request }) => {
|
||||||
|
trendDays.push(new URL(request.url).searchParams.get('days'));
|
||||||
|
return HttpResponse.json([
|
||||||
|
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20, totalImages: 1, imageRate: 10 },
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
http.get('/api/analytics/ai-usage', ({ request }) => {
|
||||||
|
usageDays.push(new URL(request.url).searchParams.get('days'));
|
||||||
|
return HttpResponse.json({
|
||||||
|
days: 7, requests: 0, successes: 0, refusals: 0, errors: 0,
|
||||||
|
promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0, averageLatencyMs: 0, models: [],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage(<PerformancePage />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('list', { name: '30 day response trend' })).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: '7 days' }));
|
||||||
|
expect(await screen.findByRole('list', { name: '7 day response trend' })).toBeInTheDocument();
|
||||||
|
expect(trendDays).toEqual(['30', '7']);
|
||||||
|
expect(usageDays).toEqual(['30', '7']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OperationsPage', () => {
|
describe('OperationsPage', () => {
|
||||||
@@ -181,11 +256,14 @@ describe('OperationsPage', () => {
|
|||||||
'href',
|
'href',
|
||||||
'https://www.reddit.com/u/operator',
|
'https://www.reddit.com/u/operator',
|
||||||
);
|
);
|
||||||
|
expect(within(consoleRegion).getByText('2 retained')).toBeInTheDocument();
|
||||||
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
|
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
|
||||||
expect(consoleRegion).not.toHaveTextContent('Sync complete');
|
expect(consoleRegion).not.toHaveTextContent('Sync complete');
|
||||||
expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
|
expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
|
||||||
|
expect(within(consoleRegion).getByText('1 shown · 2 retained')).toBeInTheDocument();
|
||||||
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
|
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
|
||||||
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
|
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
|
||||||
|
expect(within(consoleRegion).getByText('0 shown · 2 retained')).toBeInTheDocument();
|
||||||
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
|
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked();
|
expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked();
|
||||||
await user.clear(within(consoleRegion).getByLabelText('Search logs'));
|
await user.clear(within(consoleRegion).getByLabelText('Search logs'));
|
||||||
@@ -204,19 +282,29 @@ describe('OperationsPage', () => {
|
|||||||
http.post('/api/chats/sync/full', () => { fullCalls += 1; return HttpResponse.json({ success: true, kind: 'full' }); }),
|
http.post('/api/chats/sync/full', () => { fullCalls += 1; return HttpResponse.json({ success: true, kind: 'full' }); }),
|
||||||
http.post('/api/chats/fetch-all', () => HttpResponse.json({ success: true, count: 7 })),
|
http.post('/api/chats/fetch-all', () => HttpResponse.json({ success: true, count: 7 })),
|
||||||
);
|
);
|
||||||
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(false);
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderPage(<OperationsPage realtimeEnabled={false} />);
|
renderPage(<OperationsPage realtimeEnabled={false} />);
|
||||||
await user.click(await screen.findByRole('button', { name: 'Sync latest chats' }));
|
await user.click(await screen.findByRole('button', { name: 'Sync latest chats' }));
|
||||||
expect(await screen.findByText(/Delta sync started/)).toBeInTheDocument();
|
expect(await screen.findByText(/Delta sync started/)).toBeInTheDocument();
|
||||||
expect(deltaCalls).toBe(1);
|
expect(deltaCalls).toBe(1);
|
||||||
|
|
||||||
|
// Expensive operations require an in-app confirmation dialog with action-specific copy.
|
||||||
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
|
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
|
||||||
|
const fullDialog = screen.getByRole('dialog', { name: 'Run a full cache sync?' });
|
||||||
|
expect(fullDialog).toHaveTextContent('can take significantly longer than a delta sync');
|
||||||
|
await user.click(within(fullDialog).getByRole('button', { name: 'Cancel' }));
|
||||||
expect(fullCalls).toBe(0);
|
expect(fullCalls).toBe(0);
|
||||||
confirmSpy.mockReturnValue(true);
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
|
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
|
||||||
|
await user.click(within(screen.getByRole('dialog', { name: 'Run a full cache sync?' })).getByRole('button', { name: 'Run full sync' }));
|
||||||
expect(await screen.findByText(/Full sync started/)).toBeInTheDocument();
|
expect(await screen.findByText(/Full sync started/)).toBeInTheDocument();
|
||||||
expect(fullCalls).toBe(1);
|
expect(fullCalls).toBe(1);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Fetch all chats' }));
|
await user.click(screen.getByRole('button', { name: 'Fetch all chats' }));
|
||||||
|
const fetchAllDialog = screen.getByRole('dialog', { name: 'Fetch every chat now?' });
|
||||||
|
expect(fetchAllDialog).toHaveTextContent('legacy blocking operation');
|
||||||
|
await user.click(within(fetchAllDialog).getByRole('button', { name: 'Start fetching' }));
|
||||||
expect(await screen.findByText('Fetched 7 chats.')).toBeInTheDocument();
|
expect(await screen.findByText('Fetched 7 chats.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { HttpResponse, http } from 'msw';
|
import { HttpResponse, http } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
@@ -30,7 +30,10 @@ function renderSettings(path = '/settings') {
|
|||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
});
|
});
|
||||||
const router = createMemoryRouter(
|
const router = createMemoryRouter(
|
||||||
[{ path: '/settings', element: <SettingsRoute /> }],
|
[
|
||||||
|
{ path: '/settings', element: <SettingsRoute /> },
|
||||||
|
{ path: '/elsewhere', element: <h1>Elsewhere</h1> },
|
||||||
|
],
|
||||||
{ initialEntries: [path] },
|
{ initialEntries: [path] },
|
||||||
);
|
);
|
||||||
const view = render(
|
const view = render(
|
||||||
@@ -112,9 +115,18 @@ describe('settings and access', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||||
expect(screen.getByText('Recent window must be greater than 0 minutes.')).toBeInTheDocument();
|
expect(screen.getByText('Recent window must be greater than 0 minutes.')).toBeInTheDocument();
|
||||||
expect(recentWindow).toHaveAttribute('aria-invalid', 'true');
|
expect(recentWindow).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
expect(screen.getByText('Resolve 1 validation error before saving.')).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(recentWindow).toHaveFocus());
|
||||||
expect(updateRequests).toBe(0);
|
expect(updateRequests).toBe(0);
|
||||||
|
|
||||||
|
// Discarding a dirty draft requires explicit confirmation.
|
||||||
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
|
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
|
||||||
|
const resetDialog = screen.getByRole('dialog', { name: 'Discard unsaved settings?' });
|
||||||
|
await user.click(within(resetDialog).getByRole('button', { name: 'Stay' }));
|
||||||
|
expect(recentWindow).toHaveValue(0);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
|
||||||
|
await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard changes' }));
|
||||||
expect(recentWindow).toHaveValue(60);
|
expect(recentWindow).toHaveValue(60);
|
||||||
expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument();
|
expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -340,6 +352,146 @@ describe('settings and access', () => {
|
|||||||
expect(updateRequests).toBe(0);
|
expect(updateRequests).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('confirms in-app navigation away from dirty settings and can discard the draft', async () => {
|
||||||
|
installConfigApi();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { router } = renderSettings('/settings?section=monitoring');
|
||||||
|
|
||||||
|
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
|
||||||
|
await user.clear(recentWindow);
|
||||||
|
await user.type(recentWindow, '45');
|
||||||
|
|
||||||
|
await act(async () => { await router.navigate('/elsewhere'); });
|
||||||
|
const stayDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' });
|
||||||
|
await user.click(within(stayDialog).getByRole('button', { name: 'Stay' }));
|
||||||
|
expect(router.state.location.pathname).toBe('/settings');
|
||||||
|
expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(45);
|
||||||
|
|
||||||
|
await act(async () => { await router.navigate('/elsewhere'); });
|
||||||
|
const discardDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' });
|
||||||
|
await user.click(within(discardDialog).getByRole('button', { name: 'Discard changes' }));
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/elsewhere'));
|
||||||
|
expect(screen.getByRole('heading', { name: 'Elsewhere' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires confirmation for an autonomous mode on a renamed subreddit, matched by name not index', async () => {
|
||||||
|
const r4rConfig = {
|
||||||
|
...config,
|
||||||
|
subreddits: [{
|
||||||
|
name: 'casualconversation', type: 'chat', weight: 2, tone: 'default',
|
||||||
|
conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let updateRequests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/config', () => HttpResponse.json(r4rConfig)),
|
||||||
|
http.put('/api/config', () => {
|
||||||
|
updateRequests += 1;
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
|
||||||
|
subreddits: r4rConfig.subreddits,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSettings('/settings?section=behaviour');
|
||||||
|
|
||||||
|
// The persisted row is already r4r at index 0; giving the row a new name is
|
||||||
|
// equivalent to inserting a new r4r-enabled row, so it must be re-confirmed.
|
||||||
|
const name = await screen.findByRole('textbox', { name: 'Subreddit 1 name' });
|
||||||
|
await user.clear(name);
|
||||||
|
await user.type(name, 'brandnewplace');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
|
||||||
|
expect(dialog).toHaveTextContent('Autonomous R4R will send conversational replies without approval');
|
||||||
|
expect(updateRequests).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-confirm an unchanged autonomous subreddit when a row before it is removed', async () => {
|
||||||
|
const twoSubredditConfig = {
|
||||||
|
...config,
|
||||||
|
subreddits: [
|
||||||
|
{ name: 'general', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 } },
|
||||||
|
{ name: 'r4rlondon', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let updateRequests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/config', () => HttpResponse.json(twoSubredditConfig)),
|
||||||
|
http.put('/api/config', async ({ request }) => {
|
||||||
|
updateRequests += 1;
|
||||||
|
const body = await request.json() as { subreddits: typeof twoSubredditConfig.subreddits };
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
|
||||||
|
subreddits: body.subreddits,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSettings('/settings?section=behaviour');
|
||||||
|
|
||||||
|
// Removing the first row shifts the r4r row to index 0; by-name matching
|
||||||
|
// must recognise it as already approved and save without a risk dialog.
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Remove subreddit 1' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'Confirm risky settings' })).not.toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Settings saved.')).toBeInTheDocument();
|
||||||
|
expect(updateRequests).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notices a server-side config change while the draft is dirty and loads it on reset', async () => {
|
||||||
|
installConfigApi();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { queryClient } = renderSettings('/settings?section=monitoring');
|
||||||
|
|
||||||
|
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
|
||||||
|
await user.clear(recentWindow);
|
||||||
|
await user.type(recentWindow, '45');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
queryClient.setQueryData(['dashboard', 'config'], {
|
||||||
|
...config,
|
||||||
|
reddit: { ...config.reddit, maxReadyCandidates: 99 },
|
||||||
|
subreddits: [{
|
||||||
|
name: 'casualconversation', type: 'chat', weight: 2, tone: 'default',
|
||||||
|
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('Settings changed on the server — Reset to load them.')).toBeInTheDocument();
|
||||||
|
expect(recentWindow).toHaveValue(45);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
|
||||||
|
await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard changes' }));
|
||||||
|
expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(60);
|
||||||
|
expect(screen.getByRole('spinbutton', { name: 'Maximum ready posts' })).toHaveValue(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-JSON session dump inline without sending a request', async () => {
|
||||||
|
let importRequests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/config', () => HttpResponse.json(config)),
|
||||||
|
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })),
|
||||||
|
http.post('/api/login/session-import', () => {
|
||||||
|
importRequests += 1;
|
||||||
|
return HttpResponse.json({ ok: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSettings('/settings?section=access');
|
||||||
|
|
||||||
|
const brokenDump = new File(['definitely not json'], 'broken.json', { type: 'application/json' });
|
||||||
|
await user.upload(await screen.findByLabelText('Reddit session export'), brokenDump);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/isn't valid JSON/)).toBeInTheDocument();
|
||||||
|
expect(importRequests).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('waits for and imports an extension dump without exposing credentials', async () => {
|
it('waits for and imports an extension dump without exposing credentials', async () => {
|
||||||
let statusRequests = 0;
|
let statusRequests = 0;
|
||||||
let importRequests = 0;
|
let importRequests = 0;
|
||||||
|
|||||||
Reference in new issue
Block a user