feat: add conversation image previews and sharing
This commit is contained in:
1 parent
488145340d
commit
b573b88b08
20 files changed
+634
-26
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
export interface OriginPost {
|
||||
title: string;
|
||||
@@ -35,23 +35,75 @@ function PostOriginMeta({ post }: { post: OriginPost }) {
|
||||
|
||||
export function OriginPostCard({ post }: { post: OriginPost }) {
|
||||
const [isImageRevealed, setIsImageRevealed] = 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 (
|
||||
<div className="origin-post-card">
|
||||
{post.imageUrl ? (
|
||||
<div className="origin-post-image">
|
||||
<img alt={`Source post: ${post.title}`} className={isImageRevealed ? undefined : 'is-blurred'} loading="lazy" src={post.imageUrl} />
|
||||
<button aria-pressed={isImageRevealed} onClick={() => setIsImageRevealed((current) => !current)} type="button">
|
||||
{isImageRevealed ? 'Blur original image' : 'Reveal original image'}
|
||||
</button>
|
||||
<>
|
||||
<div className="origin-post-card">
|
||||
{post.imageUrl ? (
|
||||
<div className="origin-post-image">
|
||||
<button
|
||||
aria-label={`View source image full screen: ${post.title}`}
|
||||
className="origin-post-image-preview-trigger"
|
||||
disabled={!isImageRevealed}
|
||||
onClick={() => setIsImagePreviewOpen(true)}
|
||||
ref={previewTriggerRef}
|
||||
type="button"
|
||||
>
|
||||
<img alt={`Source post: ${post.title}`} className={isImageRevealed ? undefined : 'is-blurred'} loading="lazy" src={post.imageUrl} />
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={isImageRevealed}
|
||||
className="origin-post-image-reveal"
|
||||
onClick={() => setIsImageRevealed((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{isImageRevealed ? 'Blur original image' : 'Reveal original image'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<a aria-label={post.title} className="origin-post-copy" href={post.url} rel="noreferrer" target="_blank">
|
||||
<PostOriginMeta post={post} />
|
||||
<strong>{post.title}</strong>
|
||||
<span>Open original post</span>
|
||||
</a>
|
||||
</div>
|
||||
{isImagePreviewOpen && post.imageUrl ? (
|
||||
<div
|
||||
aria-label={`Source image: ${post.title}`}
|
||||
aria-modal="true"
|
||||
className="origin-image-preview-backdrop"
|
||||
onMouseDown={(event) => { if (event.target === event.currentTarget) closeImagePreview(); }}
|
||||
role="dialog"
|
||||
>
|
||||
<div className="origin-image-preview-content">
|
||||
<img alt={`Source post: ${post.title}`} src={post.imageUrl} />
|
||||
<button aria-label="Close source image preview" onClick={closeImagePreview} ref={previewCloseRef} type="button">×</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<a aria-label={post.title} className="origin-post-copy" href={post.url} rel="noreferrer" target="_blank">
|
||||
<PostOriginMeta post={post} />
|
||||
<strong>{post.title}</strong>
|
||||
<span>Open original post</span>
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,18 @@ import type { ZodType } from 'zod';
|
||||
import {
|
||||
ConversationsResponseSchema,
|
||||
ConversationDetailResponseSchema,
|
||||
ConversationMediaShareOptionsResponseSchema,
|
||||
ErrorResponseSchema,
|
||||
ProcessedPostsResponseSchema,
|
||||
SendReplyRequestSchema,
|
||||
ShareConversationMediaRequestSchema,
|
||||
StartConversationRequestSchema,
|
||||
StartConversationResponseSchema,
|
||||
SuccessResponseSchema,
|
||||
SuggestedReplyResponseSchema,
|
||||
type ActiveChat,
|
||||
type ConversationDetailResponse,
|
||||
type ConversationMediaShareOptions,
|
||||
type ProcessedPost,
|
||||
type SuccessResponse,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
@@ -34,8 +37,10 @@ export interface ConversationApi {
|
||||
detail(id: string): Promise<ConversationDetailResponse>;
|
||||
suggestReply(id: string): Promise<string>;
|
||||
suggestMediaAsk(id: string): Promise<string>;
|
||||
mediaShareOptions(id: string): Promise<ConversationMediaShareOptions>;
|
||||
processedPosts(username: string): Promise<ProcessedPost[]>;
|
||||
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
||||
shareMedia(id: string, assetId: string): Promise<SuccessResponse>;
|
||||
startConversation(accountId: string, username: string, message: string): Promise<ActiveChat>;
|
||||
}
|
||||
|
||||
@@ -66,6 +71,12 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
||||
);
|
||||
return response.suggestedReply;
|
||||
},
|
||||
async mediaShareOptions(id) {
|
||||
return parseResponse(
|
||||
await request(`${conversationPath(id)}/shareable-media`),
|
||||
ConversationMediaShareOptionsResponseSchema,
|
||||
);
|
||||
},
|
||||
async processedPosts(username) {
|
||||
return parseResponse(
|
||||
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
|
||||
@@ -83,6 +94,17 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
||||
SuccessResponseSchema,
|
||||
);
|
||||
},
|
||||
async shareMedia(id, assetId) {
|
||||
const body = ShareConversationMediaRequestSchema.parse({ assetId });
|
||||
return parseResponse(
|
||||
await request(`${conversationPath(id)}/share-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
SuccessResponseSchema,
|
||||
);
|
||||
},
|
||||
async startConversation(accountId, username, message) {
|
||||
const body = StartConversationRequestSchema.parse({
|
||||
accountId,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { ConversationDetailResponse } 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 { OriginPostCard } from '../../components/origin-post.js';
|
||||
import { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
|
||||
import {
|
||||
useConversationDetailQuery,
|
||||
useConversationMediaShareOptionsQuery,
|
||||
useSendReplyMutation,
|
||||
useShareConversationMediaMutation,
|
||||
useSuggestMediaAskMutation,
|
||||
useSuggestReplyMutation,
|
||||
} from './conversation-queries.js';
|
||||
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
|
||||
import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
|
||||
interface ConversationDetailProps {
|
||||
@@ -81,10 +89,19 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
||||
const [draft, setDraft] = useState('');
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [wasSent, setWasSent] = useState(false);
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
|
||||
const [areSavedImagesRevealed, setAreSavedImagesRevealed] = useState(false);
|
||||
const [isConfirmingMedia, setIsConfirmingMedia] = useState(false);
|
||||
const [wasMediaShared, setWasMediaShared] = useState(false);
|
||||
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const mediaShareSummaryRef = useRef<HTMLElement>(null);
|
||||
const suggestionMutation = useSuggestReplyMutation(conversationId);
|
||||
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
|
||||
const sendMutation = useSendReplyMutation(conversationId);
|
||||
const mediaOptionsQuery = useConversationMediaShareOptionsQuery(conversationId);
|
||||
const mediaShareMutation = useShareConversationMediaMutation(conversationId);
|
||||
const mediaOptions = mediaOptionsQuery.data?.assets ?? [];
|
||||
const selectedAsset: MediaLibraryAsset | null = mediaOptions.find((asset) => asset.id === selectedAssetId) ?? null;
|
||||
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
|
||||
const automationActive = conversation.automation.status === 'active' && conversation.automation.remainingTurns > 0;
|
||||
|
||||
@@ -93,6 +110,11 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
||||
queueMicrotask(() => sendButtonRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const closeMediaConfirmation = useCallback(() => {
|
||||
setIsConfirmingMedia(false);
|
||||
queueMicrotask(() => mediaShareSummaryRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
function suggestReply() {
|
||||
setWasSent(false);
|
||||
suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
|
||||
@@ -114,6 +136,17 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
||||
});
|
||||
}
|
||||
|
||||
function shareMedia() {
|
||||
if (!selectedAsset) return;
|
||||
mediaShareMutation.mutate(selectedAsset.id, {
|
||||
onSuccess() {
|
||||
setSelectedAssetId(null);
|
||||
setWasMediaShared(true);
|
||||
},
|
||||
onSettled: closeMediaConfirmation,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="reply-composer-heading" className="conversation-composer">
|
||||
<div className="conversation-section-heading">
|
||||
@@ -149,6 +182,53 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
||||
{wasSent ? (
|
||||
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
|
||||
) : null}
|
||||
<details className="conversation-media-share">
|
||||
<summary ref={mediaShareSummaryRef}>Share a saved image</summary>
|
||||
<p>Only enabled, unused images matching the latest inbound request are available.</p>
|
||||
{mediaOptionsQuery.isPending ? <span role="status">Loading saved images…</span> : 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}
|
||||
{mediaOptions.length > 0 ? (
|
||||
<>
|
||||
<button className="conversation-media-reveal" onClick={() => setAreSavedImagesRevealed((current) => !current)} type="button">
|
||||
{areSavedImagesRevealed ? 'Blur saved images' : 'Reveal saved images'}
|
||||
</button>
|
||||
<div aria-label="Saved images available to share" className="conversation-media-options" role="radiogroup">
|
||||
{mediaOptions.map((asset) => (
|
||||
<label className={selectedAssetId === asset.id ? 'is-selected' : ''} key={asset.id}>
|
||||
<input
|
||||
checked={selectedAssetId === asset.id}
|
||||
disabled={!areSavedImagesRevealed}
|
||||
name="conversation-media-asset"
|
||||
onChange={() => {
|
||||
setSelectedAssetId(asset.id);
|
||||
setWasMediaShared(false);
|
||||
}}
|
||||
type="radio"
|
||||
value={asset.id}
|
||||
/>
|
||||
<img
|
||||
alt={`Saved image tagged ${asset.tags.join(', ')}`}
|
||||
className={areSavedImagesRevealed ? undefined : 'is-blurred'}
|
||||
src={asset.urlPath}
|
||||
/>
|
||||
<span>{asset.tags.join(', ')} · {asset.safety.toUpperCase()}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="conversation-primary"
|
||||
disabled={!selectedAsset || mediaShareMutation.isPending}
|
||||
onClick={() => setIsConfirmingMedia(true)}
|
||||
type="button"
|
||||
>
|
||||
Review image share
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{mediaShareMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaShareMutation.error.message}</div> : null}
|
||||
{wasMediaShared ? <div className="conversation-inline-success" role="status">Saved image shared with {redditUsernameLabel(conversation.username)}.</div> : null}
|
||||
</details>
|
||||
<div className="conversation-actions">
|
||||
<span>{draft.length} characters</span>
|
||||
<button
|
||||
@@ -170,6 +250,14 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
||||
onConfirm={sendReply}
|
||||
/>
|
||||
) : null}
|
||||
{isConfirmingMedia && selectedAsset ? (
|
||||
<MediaShareConfirmationDialog
|
||||
asset={selectedAsset}
|
||||
isPending={mediaShareMutation.isPending}
|
||||
onCancel={closeMediaConfirmation}
|
||||
onConfirm={shareMedia}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const conversationQueryKeys = {
|
||||
all: ['conversations'] as const,
|
||||
detail: (id: string) => ['conversations', 'detail', id] as const,
|
||||
processedPosts: (username: string) => ['conversations', 'processed-posts', username] as const,
|
||||
mediaShareOptions: (id: string) => ['conversations', 'media-share-options', id] as const,
|
||||
};
|
||||
|
||||
export function useConversationsQuery() {
|
||||
@@ -27,6 +28,13 @@ export function useSuggestMediaAskMutation(id: string) {
|
||||
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
|
||||
}
|
||||
|
||||
export function useConversationMediaShareOptionsQuery(id: string) {
|
||||
return useQuery({
|
||||
queryKey: conversationQueryKeys.mediaShareOptions(id),
|
||||
queryFn: () => conversationApi.mediaShareOptions(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProcessedPostsQuery(username: string) {
|
||||
return useQuery({
|
||||
queryKey: conversationQueryKeys.processedPosts(username),
|
||||
@@ -48,6 +56,20 @@ export function useSendReplyMutation(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useShareConversationMediaMutation(id: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (assetId: string) => conversationApi.shareMedia(id, assetId),
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.mediaShareOptions(id) }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartConversationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -78,6 +78,20 @@
|
||||
.conversation-inline-error, .conversation-inline-success { padding: .65rem .75rem; border-radius: .55rem; }
|
||||
.conversation-inline-error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
||||
.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[open] > * + * { margin-top: .7rem; }
|
||||
.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-reveal { justify-self: start; }
|
||||
.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.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.conversation-media-options label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.conversation-media-options input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.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 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-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .74); }
|
||||
.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-dialog p { color: var(--muted); }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
interface MediaShareConfirmationDialogProps {
|
||||
asset: MediaLibraryAsset;
|
||||
isPending: boolean;
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
export function MediaShareConfirmationDialog({ asset, isPending, onCancel, onConfirm }: MediaShareConfirmationDialogProps) {
|
||||
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 (
|
||||
<div aria-labelledby="media-share-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
|
||||
<div className="conversation-dialog conversation-media-confirmation">
|
||||
<span className="conversation-eyebrow">Confirm image</span>
|
||||
<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>
|
||||
<img alt={`Saved image tagged ${asset.tags.join(', ')}`} src={asset.urlPath} />
|
||||
<div className="conversation-actions">
|
||||
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
|
||||
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? 'Sharing…' : 'Confirm image share'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -116,10 +116,18 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||
.origin-post-dialog-copy a { justify-self: start; text-decoration: none; }
|
||||
.origin-post-card { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .8rem; align-items: center; }
|
||||
.origin-post-image { position: relative; width: 5rem; height: 5rem; overflow: hidden; background: #050907; border-radius: .45rem; }
|
||||
.origin-post-image img { width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
|
||||
.origin-post-image-preview-trigger { display: block; width: 100%; height: 100%; min-height: 0; padding: 0; overflow: hidden; background: transparent; border: 0; border-radius: inherit; cursor: zoom-in; }
|
||||
.origin-post-image-preview-trigger:disabled { cursor: default; }
|
||||
.origin-post-image-preview-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.origin-post-image img { display: block; width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
|
||||
.origin-post-image img.is-blurred { filter: blur(18px) brightness(.55); transform: scale(1.12); }
|
||||
.origin-post-image button { 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 button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.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-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); }
|
||||
.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 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:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
.origin-post-copy { display: grid; gap: .2rem; min-width: 0; color: inherit; text-decoration: none; }
|
||||
.origin-post-card small, .origin-post-card span { color: var(--muted); font-size: .75rem; }
|
||||
.origin-post-card strong { overflow-wrap: anywhere; }
|
||||
|
||||
Reference in new issue
Block a user