Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions client/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,31 @@
"reveal": "Show on scroll up"
}
},
"feed_card": {
"title": "Feed card style",
"desc": "Choose the visual treatment used by article cards across feed, search, and tag lists",
"options": {
"default": "Default",
"editorial": "Editorial"
},
"preview": {
"default": "The current compact card with a simple surface and minimal framing.",
"editorial": "A more expressive card with a tinted surface, stronger hierarchy, and gallery-like framing."
}
},
"feed_layout": {
"title": "Feed layout",
"desc": "Choose how article cards are arranged in feed, search, and tag pages",
"preview_note": "Preview only. The selected layout is applied after saving settings.",
"options": {
"list": "Single column",
"masonry": "Masonry"
},
"preview": {
"list": "A steady reading flow with one card per row.",
"masonry": "A staggered multi-column layout that packs cards by height."
}
},
"theme_color": {
"title": "Theme color",
"desc": "Choose the accent color used by buttons, highlights, and interactive elements",
Expand Down
25 changes: 25 additions & 0 deletions client/public/locales/ja/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,31 @@
"reveal": "上スクロールで表示"
}
},
"feed_card": {
"title": "記事カードのスタイル",
"desc": "記事一覧、検索結果、タグ一覧で使うカード表現を選びます",
"options": {
"default": "標準",
"editorial": "エディトリアル"
},
"preview": {
"default": "現在のコンパクトなカード。装飾は少なく、シンプルな見た目です。",
"editorial": "淡いテーマカラー、強めの階層感、誌面のような額装感を持つ表現です。"
}
},
"feed_layout": {
"title": "記事一覧レイアウト",
"desc": "記事一覧、検索結果、タグ一覧でのカードの並び方を選びます",
"preview_note": "プレビュー専用です。保存後にサイトへ適用されます。",
"options": {
"list": "1カラム",
"masonry": "Masonry"
},
"preview": {
"list": "1行に1枚ずつ並ぶ、安定した読みやすいレイアウトです。",
"masonry": "カードの高さに応じて詰める、多段の瀑布型レイアウトです。"
}
},
"theme_color": {
"title": "テーマカラー",
"desc": "ボタンや強調表示、操作要素に使うアクセントカラーを選びます",
Expand Down
25 changes: 25 additions & 0 deletions client/public/locales/zh-CN/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,31 @@
"reveal": "上划时显示"
}
},
"feed_card": {
"title": "文章卡片样式",
"desc": "选择文章列表、搜索结果和标签页中卡片的视觉风格",
"options": {
"default": "默认",
"editorial": "杂志风"
},
"preview": {
"default": "当前这套紧凑卡片,表面处理简单,干净直接。",
"editorial": "更有展示感的卡片,带轻微主题染色、更强层级和类似画册的 framing。"
}
},
"feed_layout": {
"title": "文章列表布局",
"desc": "选择文章列表、搜索结果和标签页里卡片的排列方式",
"preview_note": "仅预览。保存设置后才会应用到站点。",
"options": {
"list": "单列",
"masonry": "瀑布流"
},
"preview": {
"list": "稳定的单列阅读节奏,每行只放一张卡片。",
"masonry": "按卡片高度自动拼接的多列布局,更适合长短不一的内容。"
}
},
"theme_color": {
"title": "主题色",
"desc": "选择按钮、高亮和交互元素使用的强调色",
Expand Down
25 changes: 25 additions & 0 deletions client/public/locales/zh-TW/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,31 @@
"reveal": "上滑時顯示"
}
},
"feed_card": {
"title": "文章卡片樣式",
"desc": "選擇文章列表、搜尋結果與標籤頁中卡片的視覺風格",
"options": {
"default": "預設",
"editorial": "雜誌風"
},
"preview": {
"default": "目前這套緊湊卡片,表面乾淨,裝飾最少。",
"editorial": "更有展示感的卡片,帶有淡淡主題染色、更強層次,以及類似畫冊的 framing。"
}
},
"feed_layout": {
"title": "文章列表版面",
"desc": "選擇文章列表、搜尋結果與標籤頁中卡片的排列方式",
"preview_note": "僅供預覽。儲存設定後才會套用到站點。",
"options": {
"list": "單欄",
"masonry": "瀑布流"
},
"preview": {
"list": "穩定的單欄閱讀節奏,每列只放一張卡片。",
"masonry": "依卡片高度自動拼接的多欄版面,適合長短不一的內容。"
}
},
"theme_color": {
"title": "主題色",
"desc": "選擇按鈕、強調與互動元素使用的主色",
Expand Down
7 changes: 7 additions & 0 deletions client/src/components/feed-card-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const FEED_CARD_VARIANTS = ["default", "editorial"] as const;

export type FeedCardVariant = (typeof FEED_CARD_VARIANTS)[number];

export function normalizeFeedCardVariant(value: string): FeedCardVariant {
return FEED_CARD_VARIANTS.includes(value as FeedCardVariant) ? (value as FeedCardVariant) : "default";
}
54 changes: 54 additions & 0 deletions client/src/components/feed-card-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { attachImageMetadataToUrl } from "../utils/image-upload";
import { FeedCard, type FeedCardProps } from "./feed_card";
import { type FeedCardVariant } from "./feed-card-options";
import { SettingsPreviewCard } from "./settings-preview-card";

const PREVIEW_IMAGE_URL = attachImageMetadataToUrl(
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 720'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%23f3d7bf'/%3E%3Cstop offset='0.55' stop-color='%23d9e7f5'/%3E%3Cstop offset='1' stop-color='%23f8c4cf'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='1200' height='720' fill='url(%23g)'/%3E%3Ccircle cx='220' cy='180' r='110' fill='rgba(255,255,255,0.45)'/%3E%3Ccircle cx='960' cy='132' r='84' fill='rgba(255,255,255,0.32)'/%3E%3Cpath d='M0 566C108 490 206 462 295 482c117 26 175 132 292 132 102 0 156-66 252-66 102 0 179 72 361 172H0Z' fill='rgba(34,34,34,0.12)'/%3E%3Cpath d='M0 610c97-40 182-49 256-28 96 28 153 101 258 101 103 0 173-70 286-70 109 0 201 53 400 107H0Z' fill='rgba(255,255,255,0.36)'/%3E%3C/svg%3E",
{
blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
width: 1200,
height: 720,
},
);

const PREVIEW_PROPS: FeedCardProps = {
id: "preview-feed-card",
title: "A quieter card with stronger hierarchy",
summary:
"This preview shows how article lists feel before you save. The image, metadata, and tags all respond to the selected feed card style.",
avatar: PREVIEW_IMAGE_URL,
hashtags: [
{ id: 1, name: "design" },
{ id: 2, name: "reading" },
],
createdAt: new Date("2026-03-01T10:00:00.000Z"),
updatedAt: new Date("2026-03-03T12:00:00.000Z"),
draft: 0,
listed: 1,
top: 1,
};

export function FeedCardPreview({
description,
selected,
title,
variant,
onClick,
}: {
description: string;
selected: boolean;
title: string;
variant: FeedCardVariant;
onClick: () => void;
}) {
return (
<SettingsPreviewCard
title={title}
description={description}
selected={selected}
onClick={onClick}
preview={<FeedCard {...PREVIEW_PROPS} preview variant={variant} />}
/>
);
}
7 changes: 7 additions & 0 deletions client/src/components/feed-layout-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const FEED_LAYOUT_OPTIONS = ["list", "masonry"] as const;

export type FeedLayout = (typeof FEED_LAYOUT_OPTIONS)[number];

export function normalizeFeedLayout(value: string): FeedLayout {
return FEED_LAYOUT_OPTIONS.includes(value as FeedLayout) ? (value as FeedLayout) : "list";
}
113 changes: 77 additions & 36 deletions client/src/components/feed_card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@ import { Link } from "wouter";
import { useTranslation } from "react-i18next";
import { timeago } from "../utils/timeago";
import { HashTag } from "./hashtag";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { drawBlurhashToCanvas } from "../utils/blurhash";
import { parseImageUrlMetadata } from "../utils/image-upload";
import { useImageLoadState } from "../utils/use-image-load-state";
import { type FeedCardVariant, normalizeFeedCardVariant } from "./feed-card-options";
import { useSiteConfig } from "../hooks/useSiteConfig";

function FeedCardImage({ src }: { src: string }) {
function FeedCardImage({ src, variant }: { src: string; variant: FeedCardVariant }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const { src: cleanSrc, blurhash, width, height } = parseImageUrlMetadata(src);
const { failed, imageRef, loaded, onError, onLoad } = useImageLoadState(cleanSrc);
const aspectRatio = width && height ? `${width} / ${height}` : undefined;
const imageFrameClass =
variant === "editorial"
? "relative flex max-h-80 w-full flex-row items-center overflow-hidden rounded-[20px]"
: "relative mb-2 flex max-h-80 w-full flex-row items-center overflow-hidden rounded-xl";

useEffect(() => {
if (!blurhash || !canvasRef.current) {
Expand All @@ -26,14 +32,14 @@ function FeedCardImage({ src }: { src: string }) {

return (
<div
className="relative mb-2 flex max-h-80 w-full flex-row items-center overflow-hidden rounded-xl"
className={imageFrameClass}
style={{ aspectRatio }}
>
{blurhash && !loaded ? (
<canvas
ref={canvasRef}
aria-hidden="true"
className="absolute inset-0 h-full w-full scale-110 blur-sm"
className="absolute inset-0 h-full w-full scale-110 object-cover blur-sm"
/>
) : null}
<img
Expand All @@ -51,52 +57,87 @@ function FeedCardImage({ src }: { src: string }) {
);
}

export function FeedCard({ id, title, avatar, draft, listed, top, summary, hashtags, createdAt, updatedAt }:
const FEED_CARD_STYLES: Record<
FeedCardVariant,
{
id: string, avatar?: string,
draft?: number, listed?: number, top?: number,
title: string, summary: string,
hashtags: { id: number, name: string }[],
createdAt: Date, updatedAt: Date
}) {
const { t } = useTranslation()
return useMemo(() => (
<>
<Link href={`/feed/${id}`} target="_blank" className="w-full rounded-2xl bg-w my-2 p-6 duration-300 bg-button">
{avatar &&
<FeedCardImage src={avatar} />}
<h1 className="text-xl font-bold text-gray-700 dark:text-white text-pretty overflow-hidden">
{title}
</h1>
<p className="space-x-2">
<span className="text-gray-400 text-sm" title={new Date(createdAt).toLocaleString()}>
card: string;
imageWrap: string;
meta: string;
summary: string;
title: string;
}
> = {
default: {
card: "my-2 inline-block w-full break-inside-avoid rounded-2xl bg-w p-6 duration-300 bg-button",
imageWrap: "",
meta: "text-gray-400 text-sm",
summary: "line-clamp-4 text-pretty overflow-hidden dark:text-neutral-500",
title: "text-xl font-bold text-gray-700 dark:text-white text-pretty overflow-hidden",
},
editorial: {
card: "my-3 inline-block w-full break-inside-avoid overflow-hidden rounded-[28px] border border-black/10 bg-w p-3 shadow-[0_24px_60px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_28px_70px_rgba(15,23,42,0.12)] dark:border-white/10",
imageWrap: "mb-3 overflow-hidden rounded-[22px] border border-black/5 dark:border-white/10",
meta: "text-[12px] font-medium uppercase tracking-[0.18em] text-neutral-500 dark:text-neutral-400",
summary: "line-clamp-5 text-pretty text-[15px] leading-7 text-neutral-600 dark:text-neutral-300",
title: "text-2xl font-semibold tracking-[-0.02em] text-neutral-900 dark:text-white text-pretty overflow-hidden",
},
};

export type FeedCardProps = {
id: string;
avatar?: string;
draft?: number;
listed?: number;
top?: number;
title: string;
summary: string;
hashtags: { id: number, name: string }[];
createdAt: Date;
updatedAt: Date;
preview?: boolean;
variant?: FeedCardVariant;
};

export function FeedCard({ id, title, avatar, draft, listed, top, summary, hashtags, createdAt, updatedAt, preview = false, variant }: FeedCardProps) {
const { t } = useTranslation();
const siteConfig = useSiteConfig();
const activeVariant = normalizeFeedCardVariant(variant ?? siteConfig.feedCardVariant);
const styles = FEED_CARD_STYLES[activeVariant];
const body = (
<div className={styles.card}>
{avatar ? (
<div className={styles.imageWrap}>
<FeedCardImage src={avatar} variant={activeVariant} />
</div>
) : null}
<div className={activeVariant === "editorial" ? "px-2 pb-2" : ""}>
<h1 className={styles.title}>{title}</h1>
<p className={`space-x-2 ${styles.meta}`}>
<span title={new Date(createdAt).toLocaleString()}>
{createdAt === updatedAt ? timeago(createdAt) : t('feed_card.published$time', { time: timeago(createdAt) })}
</span>
{createdAt !== updatedAt &&
<span className="text-gray-400 text-sm" title={new Date(updatedAt).toLocaleString()}>
<span title={new Date(updatedAt).toLocaleString()}>
{t('feed_card.updated$time', { time: timeago(updatedAt) })}
</span>
}
</p>
<p className="space-x-2">
{draft === 1 && <span className="text-gray-400 text-sm">{t("draft")}</span>}
{listed === 0 && <span className="text-gray-400 text-sm">{t("unlisted")}</span>}
{top === 1 && <span className="text-theme text-sm">
{t('article.top.title')}
</span>}
</p>
<p className="text-pretty overflow-hidden dark:text-neutral-500">
{summary}
<p className={`space-x-2 ${styles.meta} ${activeVariant === "editorial" ? "mt-2" : ""}`}>
{draft === 1 && <span>{t("draft")}</span>}
{listed === 0 && <span>{t("unlisted")}</span>}
{top === 1 && <span className="text-theme">{t('article.top.title')}</span>}
</p>
<p className={`${styles.summary} ${activeVariant === "editorial" ? "mt-4 max-w-3xl" : ""}`}>{summary}</p>
{hashtags.length > 0 &&
<div className="mt-2 flex flex-row flex-wrap justify-start gap-x-2">
<div className={`flex flex-row flex-wrap justify-start gap-2 ${activeVariant === "editorial" ? "mt-4" : "mt-2 gap-x-2"}`}>
{hashtags.map(({ name }, index) => (
<HashTag key={index} name={name} />
))}
</div>
}
</div>
</div>
);

</Link>
</>
), [id, title, avatar, draft, listed, top, summary, hashtags, createdAt, updatedAt])
return preview ? body : <Link href={`/feed/${id}`} target="_blank" className="block w-full">{body}</Link>;
}
Loading
Loading