Merge pull request 'terry-staging' (#3) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 56s
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 56s
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -6,6 +6,7 @@ import { useI18n } from "../i18n";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { formatDateYmd } from "../utils/format";
|
import { formatDateYmd } from "../utils/format";
|
||||||
import { officialRecommendationCoverFallbacks } from "./FigmaBanner";
|
import { officialRecommendationCoverFallbacks } from "./FigmaBanner";
|
||||||
|
import { downloadFile } from "./messageStream/utils/downloadFile";
|
||||||
|
|
||||||
function isPlaceholderAsset(path: string | undefined | null) {
|
function isPlaceholderAsset(path: string | undefined | null) {
|
||||||
return !path || path.includes("placeholder-cover");
|
return !path || path.includes("placeholder-cover");
|
||||||
@@ -98,7 +99,7 @@ export function RecommendedCard({
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
window.open(dl, "_blank", "noopener,noreferrer");
|
void downloadFile(dl, r.title).catch(() => {});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download className="h-5 w-5" strokeWidth={2.2} />
|
<Download className="h-5 w-5" strokeWidth={2.2} />
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{text ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(text)}
|
{autolink(text)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -80,7 +80,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{text ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(text)}
|
{autolink(text)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
import { Download } from "lucide-react";
|
import { ArrowDownToLine } from "lucide-react";
|
||||||
import { postNoBody } from "../../../api";
|
import { postNoBody } from "../../../api";
|
||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Attachment, Post } from "../../../types/post";
|
import type { Attachment, Post } from "../../../types/post";
|
||||||
|
import { downloadFile } from "../utils/downloadFile";
|
||||||
import { fileIcon } from "../utils/fileIcon";
|
import { fileIcon } from "../utils/fileIcon";
|
||||||
|
import {
|
||||||
|
filenameWithExtension,
|
||||||
|
middleEllipsisFilename,
|
||||||
|
} from "../utils/filenameDisplay";
|
||||||
import { formatBytes } from "../utils/formatBytes";
|
import { formatBytes } from "../utils/formatBytes";
|
||||||
import { postDisplayText } from "../utils/postText";
|
import { postDisplayText } from "../utils/postText";
|
||||||
|
|
||||||
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
||||||
const isImageAsDoc = att.mime.startsWith("image/");
|
const isImageAsDoc = att.mime.startsWith("image/");
|
||||||
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
||||||
|
const displayFilename = filenameWithExtension(att.filename, att.mime);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`);
|
||||||
|
void downloadFile(att.url, displayFilename).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<div className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5">
|
||||||
href={att.url}
|
<button
|
||||||
download={att.filename}
|
type="button"
|
||||||
target="_blank"
|
onClick={handleDownload}
|
||||||
rel="noopener noreferrer"
|
className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full md:h-12 md:w-12"
|
||||||
className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5"
|
aria-label={`Download ${att.filename}`}
|
||||||
onClick={() => {
|
>
|
||||||
void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full md:h-12 md:w-12">
|
|
||||||
{isImageAsDoc && att.thumbnailUrl ? (
|
{isImageAsDoc && att.thumbnailUrl ? (
|
||||||
<>
|
<img
|
||||||
<img
|
src={att.thumbnailUrl}
|
||||||
src={att.thumbnailUrl}
|
alt=""
|
||||||
alt=""
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
/>
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
|
|
||||||
<Download className="h-4 w-4 text-white" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="flex h-full w-full items-center justify-center"
|
className="flex h-full w-full items-center justify-center"
|
||||||
@@ -41,16 +43,22 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
|||||||
<Icon className="h-5 w-5 text-white" strokeWidth={2.2} />
|
<Icon className="h-5 w-5 text-white" strokeWidth={2.2} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-100 transition group-hover:bg-black/45 group-focus-visible:bg-black/45">
|
||||||
|
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-[14px] font-medium text-ark-gold2 group-hover:text-ark-gold">
|
<div
|
||||||
{att.filename}
|
className="truncate text-[14px] font-medium text-ark-gold2 group-hover:text-ark-gold"
|
||||||
|
title={displayFilename}
|
||||||
|
>
|
||||||
|
{middleEllipsisFilename(displayFilename)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-neutral-400">
|
<div className="text-[11px] text-neutral-400">
|
||||||
{formatBytes(att.sizeBytes)}
|
{formatBytes(att.sizeBytes)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +71,7 @@ export function FileDocBubble({ post }: { post: Post }) {
|
|||||||
<AttachmentRow key={att.id} postId={post.id} att={att} />
|
<AttachmentRow key={att.id} postId={post.id} att={att} />
|
||||||
))}
|
))}
|
||||||
{text ? (
|
{text ? (
|
||||||
<div className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="message-stream-copyable-text mt-1 select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{text ? (
|
{text ? (
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/55 to-transparent px-4 pb-4 pt-16 text-[14px] leading-snug text-neutral-100">
|
<div className="bg-gradient-to-b from-ark-panel/90 to-ark-panel px-4 py-3 text-[14px] leading-snug text-neutral-100">
|
||||||
<div className="whitespace-pre-wrap break-words">
|
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words">
|
||||||
{autolink(text)}
|
{autolink(text)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { postDisplayText } from "../utils/postText";
|
|||||||
export function TextBubble({ post }: { post: Post }) {
|
export function TextBubble({ post }: { post: Post }) {
|
||||||
const { lang } = useI18n();
|
const { lang } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(postDisplayText(post, lang))}
|
{autolink(postDisplayText(post, lang))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Download, Play } from "lucide-react";
|
import { ArrowDownToLine, Play } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { postNoBody } from "../../../api";
|
import { postNoBody } from "../../../api";
|
||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Post } from "../../../types/post";
|
import type { Post } from "../../../types/post";
|
||||||
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
|
import { downloadFile } from "../utils/downloadFile";
|
||||||
import { formatBytes } from "../utils/formatBytes";
|
import { formatBytes } from "../utils/formatBytes";
|
||||||
import { postDisplayText } from "../utils/postText";
|
import { postDisplayText } from "../utils/postText";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
const ratio =
|
const ratio =
|
||||||
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
|
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
|
||||||
const posterUrl = att.posterUrl ?? att.thumbnailUrl;
|
const posterUrl = att.posterUrl ?? att.thumbnailUrl;
|
||||||
|
const duration = formatDuration(att.durationSec);
|
||||||
const previewVideoUrl = att.url.includes("#") ? att.url : `${att.url}#t=0.1`;
|
const previewVideoUrl = att.url.includes("#") ? att.url : `${att.url}#t=0.1`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,33 +70,31 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="absolute left-3 top-3 z-10 flex items-center gap-1.5 text-xs text-white">
|
<button
|
||||||
<a
|
type="button"
|
||||||
href={att.url}
|
onClick={(e) => {
|
||||||
download={att.filename}
|
e.stopPropagation();
|
||||||
target="_blank"
|
void postNoBody(
|
||||||
rel="noreferrer"
|
`/api/posts/${post.id}/attachments/${att.id}/download`,
|
||||||
onClick={(e) => {
|
);
|
||||||
e.stopPropagation();
|
void downloadFile(att.url, att.filename).catch(() => {});
|
||||||
void postNoBody(
|
}}
|
||||||
`/api/posts/${post.id}/attachments/${att.id}/download`,
|
className="group absolute left-3 top-3 z-10 inline-flex overflow-hidden rounded-full bg-black/45 text-xs text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/60"
|
||||||
);
|
aria-label={`Download ${att.filename}`}
|
||||||
}}
|
>
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur transition hover:bg-black/75"
|
<span className="flex h-8 w-8 items-center justify-center bg-white/10 transition group-hover:bg-white/15">
|
||||||
aria-label={`Download ${att.filename}`}
|
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
|
||||||
>
|
</span>
|
||||||
<Download className="h-4 w-4" />
|
<span className="flex h-8 items-center gap-1 px-2.5">
|
||||||
</a>
|
{duration ? (
|
||||||
<div className="flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1.5">
|
|
||||||
{formatDuration(att.durationSec) ? (
|
|
||||||
<>
|
<>
|
||||||
<span>{formatDuration(att.durationSec)}</span>
|
<span>{duration}</span>
|
||||||
<span className="opacity-70">·</span>
|
<span className="opacity-60">·</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<span>{formatBytes(att.sizeBytes)}</span>
|
<span>{formatBytes(att.sizeBytes)}</span>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -112,7 +112,7 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{text ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(text)}
|
{autolink(text)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
|||||||
import { postNoBody } from "../../../api";
|
import { postNoBody } from "../../../api";
|
||||||
import type { Attachment } from "../../../types/post";
|
import type { Attachment } from "../../../types/post";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
|
import { downloadFile } from "../utils/downloadFile";
|
||||||
|
|
||||||
type LightboxState = {
|
type LightboxState = {
|
||||||
images: Attachment[];
|
images: Attachment[];
|
||||||
@@ -120,7 +121,7 @@ function LightboxView({
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm"
|
className="fixed inset-0 z-[100] flex flex-col bg-black/95 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -137,11 +138,8 @@ function LightboxView({
|
|||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a
|
<button
|
||||||
href={current.url}
|
type="button"
|
||||||
download={current.filename}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (postId) {
|
if (postId) {
|
||||||
@@ -149,12 +147,13 @@ function LightboxView({
|
|||||||
`/api/posts/${postId}/attachments/${current.id}/download`,
|
`/api/posts/${postId}/attachments/${current.id}/download`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
void downloadFile(current.url, current.filename).catch(() => {});
|
||||||
}}
|
}}
|
||||||
className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
>
|
>
|
||||||
<Download className="h-5 w-5" />
|
<Download className="h-5 w-5" />
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
{images.length > 1 ? (
|
{images.length > 1 ? (
|
||||||
<>
|
<>
|
||||||
@@ -180,42 +179,52 @@ function LightboxView({
|
|||||||
>
|
>
|
||||||
<ChevronRight className="h-6 w-6" />
|
<ChevronRight className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
|
<div className="absolute left-1/2 top-4 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
|
||||||
{index + 1} / {images.length}
|
{index + 1} / {images.length}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative inline-block max-h-[92vh] max-w-[92vw]"
|
className={`flex min-h-0 w-full flex-1 items-center justify-center px-4 pt-16 ${
|
||||||
onClick={(e) => e.stopPropagation()}
|
caption ? "pb-3" : "pb-16"
|
||||||
onTouchStart={(e) => {
|
}`}
|
||||||
touchStartX.current = e.touches[0].clientX;
|
|
||||||
}}
|
|
||||||
onTouchEnd={(e) => {
|
|
||||||
if (touchStartX.current == null) return;
|
|
||||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
|
||||||
if (Math.abs(dx) > 40) {
|
|
||||||
if (dx > 0) goPrev();
|
|
||||||
else goNext();
|
|
||||||
}
|
|
||||||
touchStartX.current = null;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<img
|
<div
|
||||||
src={current.url}
|
className="max-h-full max-w-[92vw]"
|
||||||
alt={current.filename}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="max-h-[92vh] max-w-[92vw] object-contain select-none"
|
onTouchStart={(e) => {
|
||||||
draggable={false}
|
touchStartX.current = e.touches[0].clientX;
|
||||||
/>
|
}}
|
||||||
{caption ? (
|
onTouchEnd={(e) => {
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pb-4 pt-12 text-sm leading-snug text-white sm:px-5 sm:pb-5">
|
if (touchStartX.current == null) return;
|
||||||
<div className="max-h-[32vh] overflow-y-auto whitespace-pre-wrap break-words">
|
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||||
{autolink(caption)}
|
if (Math.abs(dx) > 40) {
|
||||||
</div>
|
if (dx > 0) goPrev();
|
||||||
</div>
|
else goNext();
|
||||||
) : null}
|
}
|
||||||
|
touchStartX.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={current.url}
|
||||||
|
alt={current.filename}
|
||||||
|
className="max-h-full max-w-[92vw] object-contain select-none"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{caption ? (
|
||||||
|
<div
|
||||||
|
className="shrink-0 border-t border-white/10 bg-gradient-to-t from-black via-black/90 to-black/70 px-4 py-4 text-sm leading-snug text-white sm:px-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="message-stream-copyable-text mx-auto max-h-[32vh] max-w-[920px] overflow-y-auto whitespace-pre-wrap break-words [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
{autolink(caption)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function autolink(text: string): ReactNode[] {
|
|||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-ark-gold underline underline-offset-2 break-all hover:text-ark-gold2"
|
className="message-stream-copyable-text select-text break-all text-ark-gold underline underline-offset-2 hover:text-ark-gold2"
|
||||||
>
|
>
|
||||||
{url}
|
{url}
|
||||||
</a>,
|
</a>,
|
||||||
|
|||||||
78
src/components/messageStream/utils/downloadFile.ts
Normal file
78
src/components/messageStream/utils/downloadFile.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
type SaveFilePicker = (options?: {
|
||||||
|
suggestedName?: string;
|
||||||
|
types?: Array<{
|
||||||
|
description?: string;
|
||||||
|
accept: Record<string, string[]>;
|
||||||
|
}>;
|
||||||
|
}) => Promise<{
|
||||||
|
createWritable: () => Promise<{
|
||||||
|
write: (data: Blob) => Promise<void>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type NavigatorWithFileShare = Navigator & {
|
||||||
|
canShare?: (data: { files?: File[] }) => boolean;
|
||||||
|
share?: (data: { files?: File[]; title?: string }) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WindowWithSavePicker = Window & {
|
||||||
|
showSaveFilePicker?: SaveFilePicker;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function downloadFile(url: string, filename: string) {
|
||||||
|
const res = await fetch(url, { credentials: "include" });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const safeName = filename || "download";
|
||||||
|
|
||||||
|
if (window.isSecureContext) {
|
||||||
|
const picker = (window as WindowWithSavePicker).showSaveFilePicker;
|
||||||
|
if (picker) {
|
||||||
|
const handle = await picker({
|
||||||
|
suggestedName: safeName,
|
||||||
|
types: blob.type
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
description: "File",
|
||||||
|
accept: { [blob.type]: [extensionFromName(safeName)] },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
await writable.write(blob);
|
||||||
|
await writable.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = new File([blob], safeName, {
|
||||||
|
type: blob.type || "application/octet-stream",
|
||||||
|
});
|
||||||
|
const nav = navigator as NavigatorWithFileShare;
|
||||||
|
if (nav.canShare?.({ files: [file] }) && nav.share) {
|
||||||
|
await nav.share({ files: [file], title: safeName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
triggerDownload(objectUrl, safeName);
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(url: string, filename: string) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.append(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromName(filename: string) {
|
||||||
|
const match = /\.[^.]+$/.exec(filename);
|
||||||
|
return match?.[0] || ".bin";
|
||||||
|
}
|
||||||
47
src/components/messageStream/utils/filenameDisplay.test.ts
Normal file
47
src/components/messageStream/utils/filenameDisplay.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
filenameWithExtension,
|
||||||
|
middleEllipsisFilename,
|
||||||
|
} from "./filenameDisplay";
|
||||||
|
|
||||||
|
describe("filenameWithExtension", () => {
|
||||||
|
it("keeps filenames that already have an extension", () => {
|
||||||
|
expect(filenameWithExtension("sample.pdf", "image/png")).toBe("sample.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds common extensions from mime type", () => {
|
||||||
|
expect(filenameWithExtension("uuid-file", "image/png")).toBe(
|
||||||
|
"uuid-file.png",
|
||||||
|
);
|
||||||
|
expect(filenameWithExtension("uuid-file", "audio/mpeg")).toBe(
|
||||||
|
"uuid-file.mp3",
|
||||||
|
);
|
||||||
|
expect(filenameWithExtension("uuid-file", "application/pdf")).toBe(
|
||||||
|
"uuid-file.pdf",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("middleEllipsisFilename", () => {
|
||||||
|
it("keeps short filenames unchanged", () => {
|
||||||
|
expect(middleEllipsisFilename("sample.pdf")).toBe("sample.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the extension when truncating", () => {
|
||||||
|
expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7.jpg", 22)).toBe(
|
||||||
|
"afbb9ebe-5af2…-9d7.jpg",
|
||||||
|
);
|
||||||
|
expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7.png", 22)).toBe(
|
||||||
|
"afbb9ebe-5af2…-9d7.png",
|
||||||
|
);
|
||||||
|
expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7.mp3", 22)).toBe(
|
||||||
|
"afbb9ebe-5af2…-9d7.mp3",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles filenames without extension", () => {
|
||||||
|
expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7", 18)).toBe(
|
||||||
|
"afbb9ebe-5af2…-9d7",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
src/components/messageStream/utils/filenameDisplay.ts
Normal file
82
src/components/messageStream/utils/filenameDisplay.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const MIME_EXTENSION: Record<string, string> = {
|
||||||
|
"application/pdf": ".pdf",
|
||||||
|
"application/postscript": ".ai",
|
||||||
|
"application/illustrator": ".ai",
|
||||||
|
"application/zip": ".zip",
|
||||||
|
"application/x-zip-compressed": ".zip",
|
||||||
|
"application/vnd.ms-powerpoint": ".ppt",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||||
|
".pptx",
|
||||||
|
"application/msword": ".doc",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||||
|
".docx",
|
||||||
|
"audio/mpeg": ".mp3",
|
||||||
|
"audio/mp3": ".mp3",
|
||||||
|
"video/mp4": ".mp4",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function filenameWithExtension(filename: string, mime = ""): string {
|
||||||
|
if (hasFileExtension(filename)) return filename;
|
||||||
|
|
||||||
|
const ext = extensionFromMime(mime);
|
||||||
|
return ext ? `${filename}${ext}` : filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function middleEllipsisFilename(
|
||||||
|
filename: string,
|
||||||
|
maxLength = 24,
|
||||||
|
): string {
|
||||||
|
if (filename.length <= maxLength) return filename;
|
||||||
|
|
||||||
|
const { base, ext } = splitFilename(filename);
|
||||||
|
const ellipsis = "…";
|
||||||
|
const availableBaseLength = maxLength - ext.length - ellipsis.length;
|
||||||
|
|
||||||
|
if (availableBaseLength < 3) {
|
||||||
|
return `${filename.slice(0, Math.max(1, maxLength - 1))}${ellipsis}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailLength = Math.min(
|
||||||
|
4,
|
||||||
|
Math.floor(availableBaseLength / 2),
|
||||||
|
base.length,
|
||||||
|
);
|
||||||
|
const headLength = availableBaseLength - tailLength;
|
||||||
|
|
||||||
|
if (headLength + tailLength >= base.length) return filename;
|
||||||
|
return `${base.slice(0, headLength)}${ellipsis}${base.slice(-tailLength)}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFilename(filename: string): { base: string; ext: string } {
|
||||||
|
const dotIndex = filename.lastIndexOf(".");
|
||||||
|
if (!hasFileExtension(filename)) return { base: filename, ext: "" };
|
||||||
|
return {
|
||||||
|
base: filename.slice(0, dotIndex),
|
||||||
|
ext: filename.slice(dotIndex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFileExtension(filename: string): boolean {
|
||||||
|
const dotIndex = filename.lastIndexOf(".");
|
||||||
|
return (
|
||||||
|
dotIndex > 0 &&
|
||||||
|
dotIndex < filename.length - 1 &&
|
||||||
|
filename.length - dotIndex <= 12
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromMime(mime: string): string {
|
||||||
|
const cleanMime = mime.toLowerCase().split(";")[0].trim();
|
||||||
|
if (!cleanMime) return "";
|
||||||
|
if (MIME_EXTENSION[cleanMime]) return MIME_EXTENSION[cleanMime];
|
||||||
|
|
||||||
|
if (cleanMime.startsWith("image/")) return `.${cleanMime.slice(6)}`;
|
||||||
|
if (cleanMime.startsWith("video/")) return `.${cleanMime.slice(6)}`;
|
||||||
|
if (cleanMime.startsWith("audio/")) return `.${cleanMime.slice(6)}`;
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -41,3 +41,19 @@ header button {
|
|||||||
.gold-underline {
|
.gold-underline {
|
||||||
box-shadow: inset 0 -2px 0 #eeb726;
|
box-shadow: inset 0 -2px 0 #eeb726;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-stream-copyable-text,
|
||||||
|
.message-stream-copyable-text * {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stream-copyable-text {
|
||||||
|
-webkit-touch-callout: default;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stream-copyable-text::selection,
|
||||||
|
.message-stream-copyable-text *::selection {
|
||||||
|
background: rgba(238, 183, 38, 0.35);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Globe, Menu, Search as SearchIcon, X } from "lucide-react";
|
import {
|
||||||
import { useState } from "react";
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Globe,
|
||||||
|
Menu,
|
||||||
|
Search as SearchIcon,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ArkLogoMark } from "../components/ArkLogoMark";
|
import { ArkLogoMark } from "../components/ArkLogoMark";
|
||||||
import { useI18n, type Lang } from "../i18n";
|
import { useI18n, type Lang } from "../i18n";
|
||||||
@@ -55,6 +62,105 @@ function navClassName(active: boolean) {
|
|||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LanguageDropdownProps = {
|
||||||
|
lang: Lang;
|
||||||
|
setLang: (lang: Lang) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LanguageDropdown({
|
||||||
|
lang,
|
||||||
|
setLang,
|
||||||
|
ariaLabel,
|
||||||
|
className = "",
|
||||||
|
}: LanguageDropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selected = LANG_OPTIONS.find((option) => option.code === lang);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const closeOnOutside = (event: MouseEvent | TouchEvent) => {
|
||||||
|
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", closeOnOutside);
|
||||||
|
document.addEventListener("touchstart", closeOnOutside);
|
||||||
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", closeOnOutside);
|
||||||
|
document.removeEventListener("touchstart", closeOnOutside);
|
||||||
|
window.removeEventListener("keydown", closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={`relative ${className}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
className="flex h-full w-full items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 text-sm text-neutral-200 shadow-inner outline-none transition hover:border-ark-gold/50 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<Globe size={16} className="shrink-0 text-ark-gold/80" aria-hidden />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left">
|
||||||
|
{selected?.label ?? lang}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`shrink-0 text-neutral-400 transition ${
|
||||||
|
open ? "rotate-180 text-ark-gold" : ""
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-1.5 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{LANG_OPTIONS.map((option) => {
|
||||||
|
const active = option.code === lang;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.code}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => {
|
||||||
|
setLang(option.code as Lang);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition ${
|
||||||
|
active
|
||||||
|
? "bg-ark-gold/10 text-ark-gold2"
|
||||||
|
: "text-neutral-200 hover:bg-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||||
|
{active ? (
|
||||||
|
<Check className="h-4 w-4" strokeWidth={2.4} />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-medium">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PublicLayout() {
|
export function PublicLayout() {
|
||||||
const { t, lang, setLang } = useI18n();
|
const { t, lang, setLang } = useI18n();
|
||||||
const { pathname, search, hash } = useLocation();
|
const { pathname, search, hash } = useLocation();
|
||||||
@@ -154,25 +260,12 @@ export function PublicLayout() {
|
|||||||
className="min-w-0 flex-1 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20]"
|
className="min-w-0 flex-1 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-2 py-2 md:flex lg:px-3">
|
<LanguageDropdown
|
||||||
<Globe
|
lang={lang}
|
||||||
size={16}
|
setLang={setLang}
|
||||||
className="shrink-0 text-ark-gold/80"
|
ariaLabel={t("langLabel")}
|
||||||
aria-hidden
|
className="hidden h-10 w-36 md:block lg:w-40"
|
||||||
/>
|
/>
|
||||||
<select
|
|
||||||
className="max-w-[6.5rem] cursor-pointer truncate rounded-md bg-transparent text-sm text-neutral-200 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 lg:max-w-none"
|
|
||||||
value={lang}
|
|
||||||
onChange={(e) => setLang(e.target.value as Lang)}
|
|
||||||
aria-label={t("langLabel")}
|
|
||||||
>
|
|
||||||
{LANG_OPTIONS.map((option) => (
|
|
||||||
<option key={option.code} value={option.code}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
|
||||||
@@ -197,21 +290,12 @@ export function PublicLayout() {
|
|||||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
|
<LanguageDropdown
|
||||||
<Globe size={16} className="shrink-0 text-ark-gold/80" />
|
lang={lang}
|
||||||
<select
|
setLang={setLang}
|
||||||
className="w-full bg-transparent text-sm text-neutral-200 outline-none"
|
ariaLabel={t("langLabel")}
|
||||||
value={lang}
|
className="mb-1"
|
||||||
onChange={(e) => setLang(e.target.value as Lang)}
|
/>
|
||||||
aria-label={t("langLabel")}
|
|
||||||
>
|
|
||||||
{LANG_OPTIONS.map((option) => (
|
|
||||||
<option key={option.code} value={option.code}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className={navClassName(na("home"))}
|
className={navClassName(na("home"))}
|
||||||
@@ -326,10 +410,13 @@ export function PublicLayout() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/wallet"
|
to="/browse?sort=recommended"
|
||||||
label={t("wallet")}
|
label={t("official")}
|
||||||
icon="profile"
|
icon="heart"
|
||||||
active={pathname === "/wallet"}
|
active={
|
||||||
|
pathname === "/browse" &&
|
||||||
|
new URLSearchParams(search).get("sort") === "recommended"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/browse?sort=latest"
|
to="/browse?sort=latest"
|
||||||
@@ -356,7 +443,7 @@ function BottomNavIcon({
|
|||||||
}: {
|
}: {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: "home" | "document" | "profile" | "update";
|
icon: "home" | "document" | "heart" | "profile" | "update";
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}) {
|
}) {
|
||||||
const src = active
|
const src = active
|
||||||
|
|||||||
Reference in New Issue
Block a user