Inline banners and per-row status text reported action results all over the app; they are replaced by a small toast stack (bottom-right, Level 3 floating well styling) that only speaks for actions: successes fade, errors stay until dismissed, and form-field validation stays inline. Destructive actions no longer use bespoke inline confirm steps (the library detail's Confirm delete button) or fire immediately (duplicate copies/items, delete page selections, temp cleanup, upload discard, job cancellation): they all go through one promise-based confirm dialog (confirmAction) with a danger variant, Escape/backdrop to cancel.
606 lines
22 KiB
TypeScript
606 lines
22 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
ArrowLeft,
|
||
CheckCircle2,
|
||
Download,
|
||
ExternalLink,
|
||
Heart,
|
||
HeartOff,
|
||
} from "lucide-react";
|
||
import { useMemo, useEffect, useState } from "react";
|
||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||
|
||
import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
|
||
import { TagChip } from "@/components/TagChip";
|
||
import { DetailShell } from "@/features/detail/DetailShell";
|
||
import { api, errorMessage } from "@/lib/api";
|
||
import { cn } from "@/lib/cn";
|
||
import { renderDText } from "@/lib/dtext";
|
||
import { isTypingTarget } from "@/lib/dom";
|
||
import {
|
||
e621ErrorMessage,
|
||
effectiveCredentials,
|
||
fetchPost,
|
||
fetchPostsByIds,
|
||
RATING_LABELS,
|
||
setFavorite,
|
||
TAG_CATEGORY_LABELS,
|
||
TAG_CATEGORY_ORDER,
|
||
} from "@/lib/e621";
|
||
import { formatBytes, formatDate } from "@/lib/format";
|
||
import type { DownloadTask, Paginated } from "@/lib/types";
|
||
import { useAuth } from "@/store/auth";
|
||
import { useE621 } from "@/store/e621";
|
||
import { toast } from "@/store/toasts";
|
||
|
||
const ratingText: Record<string, string> = {
|
||
s: "text-ctp-green",
|
||
q: "text-ctp-peach",
|
||
e: "text-ctp-red",
|
||
};
|
||
|
||
const relatedBorder: Record<string, string> = {
|
||
s: "border-ctp-green/25 hover:border-ctp-green/60",
|
||
q: "border-ctp-peach/25 hover:border-ctp-peach/60",
|
||
e: "border-ctp-red/25 hover:border-ctp-red/60",
|
||
};
|
||
|
||
interface LibraryLookup {
|
||
found: string[];
|
||
j_ids: Record<string, string>;
|
||
}
|
||
|
||
export function OnlineDetail({ postId }: { postId: number }) {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const from = (location.state as { from?: string } | null)?.from ?? "/online";
|
||
const queryClient = useQueryClient();
|
||
const credentials = useE621((state) => state.credentials);
|
||
const user = useAuth((state) => state.user);
|
||
|
||
const query = useQuery({
|
||
queryKey: ["e621", "post", postId, credentials?.username ?? ""],
|
||
queryFn: ({ signal }) =>
|
||
fetchPost(effectiveCredentials(credentials), postId, signal),
|
||
enabled: Number.isFinite(postId) && postId > 0,
|
||
staleTime: 5 * 60_000,
|
||
gcTime: 30 * 60_000,
|
||
});
|
||
|
||
const post = query.data;
|
||
|
||
const relatedIds = useMemo(() => {
|
||
if (!post) return [];
|
||
const ids: number[] = [];
|
||
if (post.relationships.parent_id) ids.push(post.relationships.parent_id);
|
||
for (const childId of post.relationships.children) {
|
||
if (ids.length >= 12) break;
|
||
ids.push(childId);
|
||
}
|
||
return ids;
|
||
}, [post]);
|
||
|
||
const relatedQuery = useQuery({
|
||
queryKey: [
|
||
"e621",
|
||
"posts-by-ids",
|
||
relatedIds.join(","),
|
||
credentials?.username ?? "",
|
||
],
|
||
queryFn: ({ signal }) =>
|
||
fetchPostsByIds(effectiveCredentials(credentials), relatedIds, signal),
|
||
enabled: relatedIds.length > 0,
|
||
staleTime: 5 * 60_000,
|
||
gcTime: 30 * 60_000,
|
||
});
|
||
|
||
const lookupQuery = useQuery({
|
||
queryKey: ["library-lookup", post?.file.md5 ?? ""],
|
||
queryFn: () =>
|
||
api<LibraryLookup>("/api/files/lookup/", {
|
||
method: "POST",
|
||
json: { md5s: [post?.file.md5] },
|
||
}),
|
||
enabled: Boolean(post?.file.md5),
|
||
staleTime: 60_000,
|
||
});
|
||
const libraryJId = post
|
||
? lookupQuery.data?.j_ids?.[post.file.md5]
|
||
: undefined;
|
||
|
||
const favoriteMutation = useMutation({
|
||
mutationFn: (favorite: boolean) =>
|
||
setFavorite(effectiveCredentials(credentials), postId, favorite),
|
||
onSuccess: () => {
|
||
void queryClient.invalidateQueries({
|
||
queryKey: ["e621", "post", postId],
|
||
});
|
||
},
|
||
onError: (error) => toast.error(e621ErrorMessage(error)),
|
||
});
|
||
|
||
const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null);
|
||
|
||
// Server-side tasks outlive this page; re-attach to the newest one for this
|
||
// post so navigating away and back keeps the progress view.
|
||
const tasksQuery = useQuery({
|
||
queryKey: ["download-tasks", postId],
|
||
queryFn: () =>
|
||
api<Paginated<DownloadTask>>(
|
||
`/api/online/downloads/?post_id=${postId}`,
|
||
),
|
||
enabled: Boolean(user?.can_upload),
|
||
staleTime: 10_000,
|
||
});
|
||
const remoteActiveTask = tasksQuery.data?.results.find(
|
||
(task) => task.status === "pending" || task.status === "downloading",
|
||
);
|
||
const remoteFinishedTask = tasksQuery.data?.results.find(
|
||
(task) => task.status === "complete",
|
||
);
|
||
const effectiveTaskId = downloadTaskId ?? remoteActiveTask?.task_id ?? null;
|
||
|
||
useEffect(() => {
|
||
function handleKeyDown(event: KeyboardEvent) {
|
||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||
if (isTypingTarget(event.target)) return;
|
||
if (event.key.toLowerCase() !== "f") return;
|
||
if (!post || !credentials?.configured || favoriteMutation.isPending) return;
|
||
event.preventDefault();
|
||
favoriteMutation.mutate(!post.is_favorited);
|
||
}
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [post, credentials, favoriteMutation]);
|
||
|
||
const downloadMutation = useMutation({
|
||
mutationFn: (target: NonNullable<typeof post>) =>
|
||
api<DownloadTask>("/api/online/downloads/", {
|
||
method: "POST",
|
||
json: {
|
||
post_id: target.id,
|
||
url: target.file.url,
|
||
filename: `${target.file.md5}.${target.file.ext}`,
|
||
post: target,
|
||
},
|
||
}),
|
||
onSuccess: (task) => {
|
||
setDownloadTaskId(task.task_id);
|
||
void queryClient.invalidateQueries({ queryKey: ["status"] });
|
||
},
|
||
onError: (error) => toast.error(errorMessage(error)),
|
||
});
|
||
|
||
const taskQuery = useQuery({
|
||
queryKey: ["download-task", effectiveTaskId],
|
||
queryFn: () =>
|
||
api<DownloadTask>(`/api/online/downloads/${effectiveTaskId}/`),
|
||
enabled: Boolean(effectiveTaskId),
|
||
refetchInterval: (query) => {
|
||
const status = query.state.data?.status;
|
||
return status === "pending" || status === "downloading" ? 700 : false;
|
||
},
|
||
});
|
||
const downloadTask = taskQuery.data;
|
||
const downloadStatus = downloadTask?.status;
|
||
const downloadActive =
|
||
downloadStatus === "pending" || downloadStatus === "downloading";
|
||
|
||
useEffect(() => {
|
||
if (!downloadStatus) return;
|
||
if (
|
||
downloadStatus === "complete" ||
|
||
downloadStatus === "error" ||
|
||
downloadStatus === "cancelled"
|
||
) {
|
||
void queryClient.invalidateQueries({ queryKey: ["status"] });
|
||
}
|
||
if (downloadStatus === "complete") {
|
||
void queryClient.invalidateQueries({ queryKey: ["library-lookup"] });
|
||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||
void queryClient.invalidateQueries({ queryKey: ["download-tasks"] });
|
||
}
|
||
}, [downloadStatus, queryClient]);
|
||
|
||
function cancelDownload() {
|
||
if (!effectiveTaskId) return;
|
||
void api(`/api/online/downloads/${effectiveTaskId}/cancel/`, {
|
||
method: "POST",
|
||
}).then(() => {
|
||
void queryClient.invalidateQueries({ queryKey: ["status"] });
|
||
void queryClient.invalidateQueries({ queryKey: ["download-tasks"] });
|
||
});
|
||
}
|
||
|
||
if (query.isPending) {
|
||
return (
|
||
<div className="flex justify-center py-24">
|
||
<Spinner className="h-6 w-6" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (query.isError || !post) {
|
||
return (
|
||
<EmptyState
|
||
title="Could not load this post"
|
||
description={
|
||
query.isError ? (
|
||
e621ErrorMessage(query.error)
|
||
) : (
|
||
<Link to="/online" className="text-ctp-blue hover:underline">
|
||
Back to the online browser
|
||
</Link>
|
||
)
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const isVideo = post.file.ext === "mp4" || post.file.ext === "webm";
|
||
const imageSrc =
|
||
post.sample.has && post.sample.url ? post.sample.url : post.file.url;
|
||
const canUpload = Boolean(user?.can_upload);
|
||
const clientDownloadUrl = post.file.url
|
||
? `/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
|
||
`${post.file.md5}.${post.file.ext}`,
|
||
)}`
|
||
: null;
|
||
|
||
return (
|
||
<DetailShell
|
||
breadcrumb={
|
||
<div className="flex items-center gap-3 text-sm">
|
||
<Link
|
||
to={from}
|
||
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" /> Online
|
||
</Link>
|
||
<span className="text-ctp-surface1">/</span>
|
||
<span className="font-mono text-xs text-ctp-overlay0">
|
||
Post #{post.id}
|
||
</span>
|
||
</div>
|
||
}
|
||
actions={
|
||
<>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
variant={post.is_favorited ? "secondary" : "primary"}
|
||
disabled={!credentials?.configured || favoriteMutation.isPending}
|
||
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
|
||
title={
|
||
credentials?.configured
|
||
? "Toggle favorite (F)"
|
||
: "Add e621 credentials in Account to favorite posts"
|
||
}
|
||
>
|
||
{post.is_favorited ? (
|
||
<HeartOff className="h-4 w-4" />
|
||
) : (
|
||
<Heart className="h-4 w-4" />
|
||
)}
|
||
{favoriteMutation.isPending
|
||
? "Saving…"
|
||
: post.is_favorited
|
||
? "Unfavorite"
|
||
: "Favorite"}
|
||
</Button>
|
||
|
||
{libraryJId ? (
|
||
<Link to={`/detail/${libraryJId}`} className={linkButtonClass}>
|
||
<CheckCircle2 className="h-4 w-4" /> In library ({libraryJId})
|
||
</Link>
|
||
) : (
|
||
<Button
|
||
variant="secondary"
|
||
disabled={
|
||
!canUpload ||
|
||
downloadMutation.isPending ||
|
||
downloadActive ||
|
||
!post.file.url
|
||
}
|
||
onClick={() => downloadMutation.mutate(post)}
|
||
title={
|
||
canUpload
|
||
? undefined
|
||
: "Your account is not allowed to add to the library"
|
||
}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
{downloadMutation.isPending || downloadActive
|
||
? "Downloading…"
|
||
: "Download to Library"}
|
||
</Button>
|
||
)}
|
||
|
||
{clientDownloadUrl ? (
|
||
<a
|
||
href={clientDownloadUrl}
|
||
className={linkButtonClass}
|
||
title="Save the original file straight to your computer"
|
||
>
|
||
<Download className="h-4 w-4" /> Download to client
|
||
</a>
|
||
) : null}
|
||
|
||
<a
|
||
href={`https://e621.net/posts/${post.id}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className={linkButtonClass}
|
||
>
|
||
<ExternalLink className="h-4 w-4" /> View on e621
|
||
</a>
|
||
</div>
|
||
|
||
{downloadTask &&
|
||
(downloadTask.status === "pending" ||
|
||
downloadTask.status === "downloading") ? (
|
||
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
|
||
<span className="text-ctp-subtext1">
|
||
Downloading to the library… {downloadTask.progress}%
|
||
</span>
|
||
<span className="font-mono text-ctp-overlay0">
|
||
{formatBytes(downloadTask.downloaded)}
|
||
{downloadTask.total > 0
|
||
? ` / ${formatBytes(downloadTask.total)}`
|
||
: ""}
|
||
{downloadTask.speed
|
||
? ` · ${(downloadTask.speed / 1024 / 1024).toFixed(1)} MB/s`
|
||
: ""}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ctp-surface0">
|
||
<div
|
||
className="h-full bg-ctp-mauve transition-[width]"
|
||
style={{ width: `${downloadTask.progress}%` }}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={cancelDownload}
|
||
className="mt-2 rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
|
||
>
|
||
cancel
|
||
</button>
|
||
</div>
|
||
) : downloadTask?.status === "complete" ? (
|
||
<p className="flex items-center gap-2 rounded-md border border-ctp-green/30 bg-ctp-green/10 px-3 py-2 text-sm text-ctp-green">
|
||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||
Downloaded to the library
|
||
{downloadTask.library_j_id ? (
|
||
<Link
|
||
to={`/detail/${downloadTask.library_j_id}`}
|
||
className="font-mono text-ctp-blue hover:underline"
|
||
>
|
||
{downloadTask.library_j_id}
|
||
</Link>
|
||
) : null}
|
||
</p>
|
||
) : downloadTask?.status === "cancelled" ? (
|
||
<p className="text-xs text-ctp-overlay0">Download cancelled.</p>
|
||
) : downloadTask?.status === "error" ? (
|
||
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
||
{downloadTask.error || "Download failed."}
|
||
</p>
|
||
) : remoteFinishedTask && !downloadMutation.isPending ? (
|
||
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
|
||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-ctp-green" />
|
||
Your last download for this post finished
|
||
{remoteFinishedTask.library_j_id ? (
|
||
<>
|
||
{" — "}
|
||
<Link
|
||
to={`/detail/${remoteFinishedTask.library_j_id}`}
|
||
className="font-mono text-ctp-blue hover:underline"
|
||
>
|
||
{remoteFinishedTask.library_j_id}
|
||
</Link>
|
||
</>
|
||
) : null}
|
||
</p>
|
||
) : null}
|
||
</>
|
||
}
|
||
media={
|
||
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
||
{isVideo ? (
|
||
<video
|
||
key={post.id}
|
||
src={post.file.url ?? undefined}
|
||
controls
|
||
autoPlay
|
||
loop
|
||
muted
|
||
className="max-h-[75vh] w-full object-contain"
|
||
/>
|
||
) : (
|
||
<img
|
||
key={post.id}
|
||
src={imageSrc ?? undefined}
|
||
alt={`Post #${post.id}`}
|
||
className="max-h-[75vh] w-full object-contain"
|
||
/>
|
||
)}
|
||
</div>
|
||
}
|
||
aside={
|
||
<>
|
||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||
<h2 className="text-sm font-semibold text-ctp-subtext1">Tags</h2>
|
||
<div className="mt-3 flex flex-col gap-3">
|
||
{TAG_CATEGORY_ORDER.map((category) => {
|
||
const tags = post.tags[category] ?? [];
|
||
if (tags.length === 0) return null;
|
||
return (
|
||
<div key={category}>
|
||
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-ctp-overlay0">
|
||
{TAG_CATEGORY_LABELS[category] ?? category}
|
||
</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{tags.map((tag) => (
|
||
<TagChip
|
||
key={tag}
|
||
tag={tag}
|
||
category={category}
|
||
followable
|
||
onClick={() =>
|
||
navigate(`/online?tags=${encodeURIComponent(tag)}`)
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||
<h2 className="text-sm font-semibold text-ctp-subtext1">Specs</h2>
|
||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
|
||
<dt className="text-ctp-overlay0">Rating</dt>
|
||
<dd className={cn("font-medium", ratingText[post.rating])}>
|
||
{RATING_LABELS[post.rating] ?? post.rating}
|
||
</dd>
|
||
<dt className="text-ctp-overlay0">Score</dt>
|
||
<dd className="font-mono text-ctp-subtext0">
|
||
{post.score.up} ▲ / {post.score.down} ▼
|
||
</dd>
|
||
<dt className="text-ctp-overlay0">Favorites</dt>
|
||
<dd className="font-mono text-ctp-subtext0">{post.fav_count}</dd>
|
||
<dt className="text-ctp-overlay0">Comments</dt>
|
||
<dd className="font-mono text-ctp-subtext0">
|
||
{post.comment_count}
|
||
</dd>
|
||
<dt className="text-ctp-overlay0">File</dt>
|
||
<dd className="font-mono uppercase text-ctp-subtext0">
|
||
{post.file.ext} · {formatBytes(post.file.size)}
|
||
</dd>
|
||
<dt className="text-ctp-overlay0">Dimensions</dt>
|
||
<dd className="font-mono text-ctp-subtext0">
|
||
{post.file.width}×{post.file.height}
|
||
</dd>
|
||
<dt className="text-ctp-overlay0">MD5</dt>
|
||
<dd
|
||
className="truncate font-mono text-ctp-subtext0"
|
||
title={post.file.md5}
|
||
>
|
||
{post.file.md5}
|
||
</dd>
|
||
<dt className="text-ctp-overlay0">Posted</dt>
|
||
<dd className="text-ctp-subtext0">{formatDate(post.created_at)}</dd>
|
||
{post.uploader_name ? (
|
||
<>
|
||
<dt className="text-ctp-overlay0">Uploader</dt>
|
||
<dd className="text-ctp-subtext0">{post.uploader_name}</dd>
|
||
</>
|
||
) : null}
|
||
</dl>
|
||
</section>
|
||
</>
|
||
}
|
||
footer={
|
||
<>
|
||
{post.description.trim() ? (
|
||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||
<details>
|
||
<summary className="cursor-pointer text-sm font-semibold text-ctp-subtext1">
|
||
Description
|
||
</summary>
|
||
<div className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
|
||
{renderDText(post.description, credentials?.base_url)}
|
||
</div>
|
||
</details>
|
||
</section>
|
||
) : null}
|
||
|
||
{post.sources.length > 0 ? (
|
||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||
Sources
|
||
</h2>
|
||
<ul className="mt-2 flex flex-col gap-1">
|
||
{post.sources.slice(0, 6).map((source) => (
|
||
<li key={source} className="truncate">
|
||
<a
|
||
href={source}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="font-mono text-xs text-ctp-blue hover:underline"
|
||
title={source}
|
||
>
|
||
{source}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
) : null}
|
||
|
||
{post.pools.length > 0 ? (
|
||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||
<h2 className="text-sm font-semibold text-ctp-subtext1">Pools</h2>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
{post.pools.map((poolId) => (
|
||
<a
|
||
key={poolId}
|
||
href={`https://e621.net/pools/${poolId}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="rounded border border-ctp-surface1 px-1.5 py-0.5 font-mono text-[11px] text-ctp-subtext0 transition hover:bg-ctp-surface0"
|
||
>
|
||
Pool #{poolId}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{relatedIds.length > 0 ? (
|
||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||
Related posts
|
||
</h2>
|
||
{relatedQuery.isPending ? (
|
||
<Spinner className="mt-3 h-4 w-4" />
|
||
) : (
|
||
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4 lg:grid-cols-6">
|
||
{(relatedQuery.data ?? []).map((related) => (
|
||
<Link
|
||
key={related.id}
|
||
to={`/detail/${related.id}`}
|
||
state={{ from }}
|
||
className={cn(
|
||
"overflow-hidden rounded-md border bg-ctp-mantle transition",
|
||
relatedBorder[related.rating] ?? "border-ctp-surface0",
|
||
)}
|
||
>
|
||
<div className="aspect-square overflow-hidden">
|
||
{related.preview.url ? (
|
||
<img
|
||
src={related.preview.url}
|
||
alt={`Post #${related.id}`}
|
||
loading="lazy"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<p className="px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
|
||
#{related.id}
|
||
</p>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
) : null}
|
||
</>
|
||
}
|
||
/>
|
||
);
|
||
}
|