diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 96265fff..d7910763 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -120,7 +120,9 @@ } }, "logout": "Logout", + "main_content": "Main content", "next": "Next Page", + "no_more": "No More", "preview": "Preview", "previous": "Previous Page", "publish": { @@ -213,7 +215,6 @@ "title": "Top" }, "unlisted": "Unlisted", - "untitled": "Untitled", "untop": { "title": "Untop" }, diff --git a/client/public/locales/ja/translation.json b/client/public/locales/ja/translation.json index e909e777..de491513 100644 --- a/client/public/locales/ja/translation.json +++ b/client/public/locales/ja/translation.json @@ -120,7 +120,9 @@ } }, "logout": "ログアウト", + "main_content": "本文", "next": "次のページ", + "no_more": "もうない", "preview": "プレビュー", "previous": "前のページ", "publish": { @@ -213,7 +215,6 @@ "title": "キャッシュをクリア" }, "unlisted": "リストされていない", - "untitled": "無題", "untop": { "title": "トップ解除" }, diff --git a/client/public/locales/zh-CN/translation.json b/client/public/locales/zh-CN/translation.json index 628f441f..89ea98ea 100644 --- a/client/public/locales/zh-CN/translation.json +++ b/client/public/locales/zh-CN/translation.json @@ -120,7 +120,9 @@ } }, "logout": "退出登录", + "main_content": "正文", "next": "下一页", + "no_more": "没有更多了", "preview": "预览", "previous": "上一页", "publish": { @@ -213,7 +215,6 @@ "title": "置顶" }, "unlisted": "未列出", - "untitled": "未列出", "untop": { "title": "取消置顶" }, diff --git a/client/public/locales/zh-TW/translation.json b/client/public/locales/zh-TW/translation.json index fed5b878..c2e77f75 100644 --- a/client/public/locales/zh-TW/translation.json +++ b/client/public/locales/zh-TW/translation.json @@ -120,7 +120,9 @@ } }, "logout": "登出", + "main_content": "正文", "next": "下一頁", + "no_more": "沒有更多了", "preview": "預覽", "previous": "上一頁", "publish": { @@ -213,7 +215,6 @@ "title": "置頂" }, "unlisted": "未列出", - "untitled": "未列出", "untop": { "title": "取消置頂" }, diff --git a/client/src/components/adjacent_feed.tsx b/client/src/components/adjacent_feed.tsx new file mode 100644 index 00000000..57e68f8f --- /dev/null +++ b/client/src/components/adjacent_feed.tsx @@ -0,0 +1,81 @@ +import {useEffect, useState} from "react"; +import {client} from "../main.tsx"; +import {timeago} from "../utils/timeago.ts"; +import {Link} from "wouter"; +import {useTranslation} from "react-i18next"; + +export type AdjacentFeed = { + id: number; + title: string | null; + summary: string; + hashtags: { + id: number; + name: string; + }[]; + createdAt: Date; + updatedAt: Date; +}; +export type AdjacentFeeds = { + nextFeed: AdjacentFeed | null; + previousFeed: AdjacentFeed | null; +}; + +export function AdjacentSection({id, setError}: { id: string, setError: (error: string) => void }) { + const [adjacentFeeds, setAdjacentFeeds] = useState(); + + useEffect(() => { + client.feed + .adjacent({id}) + .get() + .then(({data, error}) => { + if (error) { + setError(error.value as string); + } else if (data && typeof data !== "string") { + setAdjacentFeeds(data); + } + }); + }, [id, setError]); + return ( +
+ + +
+ ) +} + +export function AdjacentCard({data, type}: { data: AdjacentFeed | null | undefined, type: "previous" | "next" }) { + const direction = type === "previous" ? "text-start" : "text-end" + const radius = type === "previous" ? "rounded-l-2xl" : "rounded-r-2xl" + const {t} = useTranslation() + if (!data) { + return (
+

+ {type === "previous" ? "Previous" : "Next"} +

+

+ {t('no_more')} +

+
); + } + return ( + +

+ {type === "previous" ? "Previous" : "Next"} +

+

+ {data.title} +

+

+ + {data.createdAt === data.updatedAt ? timeago(data.createdAt) : t('feed_card.published$time', {time: timeago(data.createdAt)})} + + {data.createdAt !== data.updatedAt && + + {t('feed_card.updated$time', {time: timeago(data.updatedAt)})} + + } +

+ + ) +} \ No newline at end of file diff --git a/client/src/components/feed_card.tsx b/client/src/components/feed_card.tsx index 33c912c4..aa8c32a8 100644 --- a/client/src/components/feed_card.tsx +++ b/client/src/components/feed_card.tsx @@ -1,8 +1,9 @@ -import { Link } from "wouter"; -import { useTranslation } from "react-i18next"; -import { timeago } from "../utils/timeago"; -import { HashTag } from "./hashtag"; -import { useMemo } from "react"; +import {Link} from "wouter"; +import {useTranslation} from "react-i18next"; +import {timeago} from "../utils/timeago"; +import {HashTag} from "./hashtag"; +import {useMemo} from "react"; + export function FeedCard({ id, title, avatar, draft, listed, top, summary, hashtags, createdAt, updatedAt }: { id: string, avatar?: string, @@ -34,10 +35,10 @@ export function FeedCard({ id, title, avatar, draft, listed, top, summary, hasht }

- {draft === 1 && 草稿} - {listed === 0 && 未列出} + {draft === 1 && {t("draft")}} + {listed === 0 && {t("unlisted")}} {top === 1 && - 置顶 + {t('article.top.title')} }

diff --git a/client/src/page/callback.tsx b/client/src/page/callback.tsx index 17b51500..e299ca68 100644 --- a/client/src/page/callback.tsx +++ b/client/src/page/callback.tsx @@ -1,10 +1,10 @@ -import { useEffect } from "react"; -import { setCookie } from "typescript-cookie"; -import { useLocation, useSearch } from "wouter"; +import {useEffect} from "react"; +import {setCookie} from "typescript-cookie"; +import {useLocation, useSearch} from "wouter"; export function CallbackPage() { const searchParams = new URLSearchParams(useSearch()); - const [_, setLocation] = useLocation(); + const [, setLocation] = useLocation(); useEffect(() => { const token = searchParams.get('token'); if (token) { diff --git a/client/src/page/feed.tsx b/client/src/page/feed.tsx index e2fa6d9c..00e69f53 100644 --- a/client/src/page/feed.tsx +++ b/client/src/page/feed.tsx @@ -1,23 +1,24 @@ -import { useContext, useEffect, useRef, useState } from "react"; -import { Helmet } from "react-helmet"; -import { useTranslation } from "react-i18next"; +import {useContext, useEffect, useRef, useState} from "react"; +import {Helmet} from "react-helmet"; +import {useTranslation} from "react-i18next"; import ReactModal from "react-modal"; import Popup from "reactjs-popup"; -import { Link, useLocation } from "wouter"; -import { useAlert, useConfirm } from "../components/dialog"; -import { HashTag } from "../components/hashtag"; -import { Waiting } from "../components/loading"; -import { Markdown } from "../components/markdown"; -import { client } from "../main"; -import { ClientConfigContext } from "../state/config"; -import { ProfileContext } from "../state/profile"; -import { headersWithAuth } from "../utils/auth"; -import { siteName } from "../utils/constants"; -import { timeago } from "../utils/timeago"; -import { Button } from "../components/button"; -import { Tips } from "../components/tips"; -import { useLoginModal } from "../hooks/useLoginModal"; +import {Link, useLocation} from "wouter"; +import {useAlert, useConfirm} from "../components/dialog"; +import {HashTag} from "../components/hashtag"; +import {Waiting} from "../components/loading"; +import {Markdown} from "../components/markdown"; +import {client} from "../main"; +import {ClientConfigContext} from "../state/config"; +import {ProfileContext} from "../state/profile"; +import {headersWithAuth} from "../utils/auth"; +import {siteName} from "../utils/constants"; +import {timeago} from "../utils/timeago"; +import {Button} from "../components/button"; +import {Tips} from "../components/tips"; +import {useLoginModal} from "../hooks/useLoginModal"; import mermaid from "mermaid"; +import {AdjacentSection} from "../components/adjacent_feed.tsx"; type Feed = { id: number; @@ -39,6 +40,8 @@ type Feed = { uv: number; }; + + export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Element, clean: (id: string) => void }) { const { t } = useTranslation(); const profile = useContext(ProfileContext); @@ -46,7 +49,7 @@ export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Elemen const [error, setError] = useState(); const [headImage, setHeadImage] = useState(); const ref = useRef(""); - const [_, setLocation] = useLocation(); + const [, setLocation] = useLocation(); const { showAlert, AlertUI } = useAlert(); const { showConfirm, ConfirmUI } = useConfirm(); const [top, setTop] = useState(0); @@ -296,6 +299,7 @@ export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Elemen + {feed && }

diff --git a/client/src/page/settings.tsx b/client/src/page/settings.tsx index 61d9f251..2510e0a7 100644 --- a/client/src/page/settings.tsx +++ b/client/src/page/settings.tsx @@ -1,13 +1,21 @@ import * as Switch from '@radix-ui/react-switch'; -import { ChangeEvent, useContext, useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; +import {ChangeEvent, useContext, useEffect, useRef, useState} from "react"; +import {useTranslation} from "react-i18next"; import ReactLoading from "react-loading"; import Modal from "react-modal"; -import { Button } from "../components/button.tsx"; -import { useAlert, useConfirm } from "../components/dialog.tsx"; -import { client, oauth_url } from "../main.tsx"; -import { ClientConfigContext, ConfigWrapper, defaultClientConfig, defaultClientConfigWrapper, defaultServerConfig, defaultServerConfigWrapper, ServerConfigContext } from "../state/config.tsx"; -import { headersWithAuth } from "../utils/auth.ts"; +import {Button} from "../components/button.tsx"; +import {useAlert, useConfirm} from "../components/dialog.tsx"; +import {client, oauth_url} from "../main.tsx"; +import { + ClientConfigContext, + ConfigWrapper, + defaultClientConfig, + defaultClientConfigWrapper, + defaultServerConfig, + defaultServerConfigWrapper, + ServerConfigContext +} from "../state/config.tsx"; +import {headersWithAuth} from "../utils/auth.ts"; import '../utils/thumb.css'; @@ -81,7 +89,7 @@ export function Settings() {
-
+

{t('settings.title')} diff --git a/client/src/page/timeline.tsx b/client/src/page/timeline.tsx index 2820c906..828d3e71 100644 --- a/client/src/page/timeline.tsx +++ b/client/src/page/timeline.tsx @@ -1,11 +1,11 @@ -import { useEffect, useRef, useState } from "react" -import { Helmet } from 'react-helmet' -import { Link } from "wouter" -import { Waiting } from "../components/loading" -import { client } from "../main" -import { headersWithAuth } from "../utils/auth" -import { siteName } from "../utils/constants" -import { useTranslation } from "react-i18next"; +import {useEffect, useRef, useState} from "react" +import {Helmet} from 'react-helmet' +import {Link} from "wouter" +import {Waiting} from "../components/loading" +import {client} from "../main" +import {headersWithAuth} from "../utils/auth" +import {siteName} from "../utils/constants" +import {useTranslation} from "react-i18next"; export function TimelinePage() { @@ -63,7 +63,8 @@ export function TimelinePage() {

{feeds[+year]?.map(({ id, title, createdAt }) => ( - + ))}
diff --git a/server/src/services/feed.ts b/server/src/services/feed.ts index 91f0b9e2..d258d12e 100644 --- a/server/src/services/feed.ts +++ b/server/src/services/feed.ts @@ -1,6 +1,6 @@ -import {and, count, desc, eq, like, or} from "drizzle-orm"; -import Elysia, {t} from "elysia"; -import {XMLParser} from "fast-xml-parser"; +import { and, asc, count, desc, eq, gt, like, lt, or } from "drizzle-orm"; +import Elysia, { t } from "elysia"; +import { XMLParser } from "fast-xml-parser"; import html2md from 'html-to-md'; import type {DB} from "../_worker"; import {feeds, visits} from "../db/schema"; @@ -215,6 +215,121 @@ export function FeedService() { }; return data; }) + .get("/adjacent/:id", async ({ set, params: { id } }) => { + let id_num: number; + if (isNaN(parseInt(id))) { + const aliasRecord = await db + .select({ id: feeds.id }) + .from(feeds) + .where(eq(feeds.alias, id)); + if (aliasRecord.length === 0) { + set.status = 404; + return "Not found"; + } + id_num = aliasRecord[0].id; + } else { + id_num = parseInt(id); + } + + const cache = PublicCache(); + function formatAndCacheData( + feed: any, + feedDirection: "previous_feed" | "next_feed", + ) { + if (feed) { + const hashtags_flatten = feed.hashtags.map((f: any) => f.hashtag); + const summary = + feed.summary.length > 0 + ? feed.summary + : feed.content.length > 50 + ? feed.content.slice(0, 50) + : feed.content; + // NOTE: feed.id is adjacent feed, id_num is current feed id + const cacheKey = `${feed.id}_${feedDirection}_${id_num}`; + const cacheData = { + id: feed.id, + title: feed.title, + summary: summary, + hashtags: hashtags_flatten, + createdAt: feed.createdAt, + updatedAt: feed.updatedAt, + }; + cache.set(cacheKey, cacheData); + return cacheData; + } + return null; + } + const getPreviousFeed = async () => { + // It should return an array with only one data item + const previousFeedCached = await cache.getBySuffix( + `previous_feed_${id_num}`, + ); + if (previousFeedCached && previousFeedCached.length > 0) { + return previousFeedCached[0]; + } else { + const tempPreviousFeed = await db.query.feeds.findFirst({ + where: and( + and(eq(feeds.draft, 0), eq(feeds.listed, 1)), + lt(feeds.id, id_num), + ), + orderBy: [desc(feeds.id)], + with: { + hashtags: { + columns: {}, + with: { + hashtag: { + columns: { id: true, name: true }, + }, + }, + }, + user: { + columns: { id: true, username: true, avatar: true }, + }, + }, + }); + return formatAndCacheData(tempPreviousFeed, "previous_feed"); + } + }; + const getNextFeed = async () => { + const nextFeedCached = await cache.getBySuffix( + `next_feed_${id_num}`, + ); + if (nextFeedCached && nextFeedCached.length > 0) { + return nextFeedCached[0]; + } else { + const tempNextFeed = await db.query.feeds.findFirst({ + where: and( + and(eq(feeds.draft, 0), eq(feeds.listed, 1)), + gt(feeds.id, id_num), + ), + orderBy: [asc(feeds.id)], + with: { + hashtags: { + columns: {}, + with: { + hashtag: { + columns: { id: true, name: true }, + }, + }, + }, + user: { + columns: { id: true, username: true, avatar: true }, + }, + }, + }); + return formatAndCacheData(tempNextFeed, "next_feed"); + } + }; + + const [previousFeed, nextFeed] = await Promise.all([ + getPreviousFeed(), + getNextFeed(), + ]); + return { + previousFeed, + nextFeed, + }; + }) .post('/:id', async ({ admin, set, @@ -480,6 +595,8 @@ async function clearFeedCache(id: number, alias: string | null, newAlias: string await cache.deletePrefix('feeds_'); await cache.deletePrefix('search_'); await cache.delete(`feed_${id}`, false); + await cache.deletePrefix(`${id}_previous_feed`); + await cache.deletePrefix(`${id}_next_feed`); if (alias === newAlias) return; if (alias) await cache.delete(`feed_${alias}`, false); diff --git a/server/src/utils/cache.ts b/server/src/utils/cache.ts index d320a5ae..4cee5bb4 100644 --- a/server/src/utils/cache.ts +++ b/server/src/utils/cache.ts @@ -54,6 +54,30 @@ export class CacheImpl { } return this.cache.get(key); } + async getByPrefix(prefix: string): Promise { + if (!this.loaded) { + await this.load(); + } + const result = []; + for (let key of this.cache.keys()) { + if (key.startsWith(prefix)) { + result.push(this.cache.get(key)); + } + } + return result; + } + async getBySuffix(suffix: string): Promise { + if (!this.loaded) { + await this.load(); + } + const result = []; + for (let key of this.cache.keys()) { + if (key.endsWith(suffix)) { + result.push(this.cache.get(key)); + } + } + return result; + } async getOrSet(key: string, value: () => Promise) { const cached = await this.get(key) if (cached !== undefined) { @@ -99,7 +123,16 @@ export class CacheImpl { } await this.save(); } - + async deleteSuffix(suffix: string) { + for (let key of this.cache.keys()) { + console.log("Cache key", key); + if (key.endsWith(suffix)) { + console.log("Cache delete", key); + await this.delete(key, false); + } + } + await this.save(); + } async clear() { this.cache.clear(); await this.save();