diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index cea451a..445e957 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -10,6 +10,36 @@ jobs: runs-on: self-hosted steps: + - name: Free disk space + run: | + set +e + echo "=== Disk before cleanup ===" + df -h + # Stale act runner workspaces from previous jobs (older than 60 min). + if [ -d "$HOME/.cache/act" ]; then + du -sh "$HOME/.cache/act" 2>/dev/null + find "$HOME/.cache/act" -mindepth 1 -maxdepth 1 -type d -mmin +60 -exec rm -rf {} + 2>/dev/null + fi + # Stale runner workspaces under common locations. + for dir in "$HOME/actions-runner/_work" "$HOME/.cache/setup-node" "$HOME/.npm/_cacache"; do + if [ -d "$dir" ]; then + find "$dir" -mindepth 1 -maxdepth 2 -mmin +1440 -exec rm -rf {} + 2>/dev/null + fi + done + # Docker leftovers if docker is available. + if command -v docker >/dev/null 2>&1; then + docker image prune -af --filter "until=24h" 2>/dev/null + docker container prune -f --filter "until=24h" 2>/dev/null + docker builder prune -af --filter "until=24h" 2>/dev/null + fi + # Stale /tmp files older than 2h, keep currently-running runner files. + find /tmp -mindepth 1 -maxdepth 1 -mmin +120 \ + -not -name 'runner*' -not -name 'act*' \ + -exec rm -rf {} + 2>/dev/null + echo "=== Disk after cleanup ===" + df -h + exit 0 + - name: Checkout code uses: actions/checkout@v4 diff --git a/src/components/messageStream/CollapsibleText.tsx b/src/components/messageStream/CollapsibleText.tsx new file mode 100644 index 0000000..b0320a4 --- /dev/null +++ b/src/components/messageStream/CollapsibleText.tsx @@ -0,0 +1,127 @@ +import { AnimatePresence, m } from "framer-motion"; +import { ChevronDown } from "lucide-react"; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { useI18n } from "../../i18n"; +import { EASE_OUT } from "../../motion"; + +/** + * Collapsible long-text container. + * + * When the rendered text overflows `collapsedLines`: + * 1. The text is clipped to a clean line boundary. + * 2. A soft gradient fade hints that more content is below. + * 3. A centered pill button with a chevron icon offers a clear tap target. + * + * The clipped height is `collapsedLines × computed line-height` so the cut + * always lands at a line boundary — no half-cut characters. + * + * Motion uses the shared `EASE_OUT` curve and respects MotionProvider's + * global `reducedMotion="user"`. + */ +export function CollapsibleText({ + children, + className = "", + wrapperClassName = "", + collapsedLines = 8, +}: { + children: ReactNode; + /** Typography classes applied to the text container. */ + className?: string; + /** Outer wrapper classes (padding, etc.). */ + wrapperClassName?: string; + collapsedLines?: number; +}) { + const { t } = useI18n(); + const innerRef = useRef(null); + const [clampedHeight, setClampedHeight] = useState(0); + const [fullHeight, setFullHeight] = useState(0); + const [needsToggle, setNeedsToggle] = useState(false); + const [expanded, setExpanded] = useState(false); + + const measure = () => { + const el = innerRef.current; + if (!el) return; + const lh = parseFloat(getComputedStyle(el).lineHeight) || 24; + const ch = Math.round(lh * collapsedLines); + const fh = el.scrollHeight; + setClampedHeight(ch); + setFullHeight(fh); + setNeedsToggle(fh > ch + 4); + }; + + useLayoutEffect(() => { + measure(); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(measure); + if (innerRef.current) ro.observe(innerRef.current); + return () => ro.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children, collapsedLines]); + + useEffect(() => { + const id = window.requestAnimationFrame(measure); + return () => window.cancelAnimationFrame(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children, collapsedLines]); + + const isClamped = needsToggle && !expanded; + const targetHeight = needsToggle + ? expanded + ? fullHeight + : clampedHeight + : undefined; + + return ( +
+ +
+ {children} +
+ + {/* Soft fade hints "more content below" when clamped. */} + + {isClamped ? ( + + ) : null} + +
+ + {needsToggle ? ( + + ) : null} +
+ ); +} diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index 840e9af..fe1c68d 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -8,6 +8,7 @@ import { useLightbox } from "../overlays/ImageLightbox"; import { autolink } from "../utils/autolink"; import { computeAlbumLayout } from "../utils/albumLayout"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; const MAX_VISIBLE = 4; @@ -82,9 +83,12 @@ export function AlbumBubble({ post }: { post: Post }) { })} {text ? ( -
+ {autolink(text)} -
+ ) : null} ); diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index 864e07f..ada2c61 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -11,6 +11,7 @@ import { } from "../utils/filenameDisplay"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; import { useToast } from "../../Toast"; function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { @@ -97,9 +98,9 @@ export function FileDocBubble({ post }: { post: Post }) { ))} {text ? ( -
+ {text} -
+ ) : null} ); diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index 1332eff..809362c 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -2,6 +2,7 @@ import { useI18n } from "../../../i18n"; import type { Post } from "../../../types/post"; import { autolink } from "../utils/autolink"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; import { SingleImageFrame } from "./SingleImageFrame"; export function ImageWithTextBubble({ post }: { post: Post }) { @@ -13,9 +14,12 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
{text ? ( -
+ {autolink(text)} -
+ ) : null}
); diff --git a/src/components/messageStream/bubbles/TextBubble.tsx b/src/components/messageStream/bubbles/TextBubble.tsx index bfb8523..abc1094 100644 --- a/src/components/messageStream/bubbles/TextBubble.tsx +++ b/src/components/messageStream/bubbles/TextBubble.tsx @@ -2,12 +2,13 @@ import type { Post } from "../../../types/post"; import { useI18n } from "../../../i18n"; import { autolink } from "../utils/autolink"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; export function TextBubble({ post }: { post: Post }) { const { lang } = useI18n(); return ( -
+ {autolink(postDisplayText(post, lang))} -
+ ); } diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 9832d4b..440b384 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -7,6 +7,7 @@ import type { Attachment, Post } from "../../../types/post"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { useVideoPlayer } from "../overlays/VideoPlayer"; import { autolink } from "../utils/autolink"; +import { CollapsibleText } from "../CollapsibleText"; import { downloadAttachment } from "../utils/downloadFile"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; @@ -355,9 +356,12 @@ export function VideoBubble({ post }: { post: Post }) { })} {text ? ( -
+ {autolink(text)} -
+ ) : null} {listOpen ? ( {text ? ( -
+ {autolink(text)} -
+ ) : null} ); diff --git a/src/i18n.tsx b/src/i18n.tsx index caddf20..72f1887 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -45,6 +45,8 @@ const zhDict: Dict = { downloading: "下载中…", downloadOk: "下载完成", downloadFail: "下载失败,请重试", + showMore: "展开全部", + showLess: "收起全部", share: "分享", langLabel: "语言", admin: "后台", @@ -175,6 +177,8 @@ const enDict: Dict = { downloading: "Downloading…", downloadOk: "Download complete", downloadFail: "Download failed, please retry", + showMore: "Show all", + showLess: "Show less", share: "Share", langLabel: "Language", admin: "Admin", @@ -291,6 +295,8 @@ const languageNames: Record = { }, ja: { brand: "ARK ライブラリー", + showMore: "すべて表示", + showLess: "閉じる", lang_zh_CN: "中国語", lang_en: "英語", lang_ja: "日本語", @@ -301,6 +307,8 @@ const languageNames: Record = { }, ko: { brand: "ARK 라이브러리", + showMore: "모두 보기", + showLess: "접기", lang_zh_CN: "중국어", lang_en: "영어", lang_ja: "일본어", @@ -311,6 +319,8 @@ const languageNames: Record = { }, vi: { brand: "Thư viện ARK", + showMore: "Xem tất cả", + showLess: "Thu gọn", lang_zh_CN: "Tiếng Trung", lang_en: "Tiếng Anh", lang_ja: "Tiếng Nhật", @@ -321,6 +331,8 @@ const languageNames: Record = { }, id: { brand: "Perpustakaan ARK", + showMore: "Lihat semua", + showLess: "Tutup", lang_zh_CN: "Bahasa Tionghoa", lang_en: "Bahasa Inggris", lang_ja: "Bahasa Jepang", @@ -331,6 +343,8 @@ const languageNames: Record = { }, ms: { brand: "Perpustakaan ARK", + showMore: "Lihat semua", + showLess: "Tutup", lang_zh_CN: "Bahasa Cina", lang_en: "Bahasa Inggeris", lang_ja: "Bahasa Jepun",