diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 2051ef0e..33a9ff8d 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -7,11 +7,26 @@ "confirm": "Are you sure you want to delete this article?", "title": "Delete Article" }, + "search": { + "placeholder": "Please enter keywords", + "title": "Search", + "title$keyword": "Search: {{keyword}}" + }, "title": "Articles", + "top": { + "confirm": "Are you sure you want to top this article?", + "success": "Topped successfully", + "title": "Top" + }, "total_short$count_one": "{{count}} articles", "total_short$count_other": "{{count}} articles", "total$count_one": "{{count}} articles in total", - "total$count_other": "{{count}} articles in total" + "total$count_other": "{{count}} articles in total", + "untop": { + "confirm": "Are you sure you want to untop this article?", + "success": "Untopped successfully", + "title": "Untop" + } }, "avatar": { "url": "Avatar URL" @@ -162,8 +177,14 @@ "timeline": "Timeline", "title": "Title", "title_empty": "Title cannot be empty", + "top": { + "title": "Top" + }, "unlisted": "Unlisted", "untitled": "Untitled", + "untop": { + "title": "Untop" + }, "update": { "success": "Update successful", "title": "Update" diff --git a/client/public/locales/ja/translation.json b/client/public/locales/ja/translation.json index f0b65800..79b31657 100644 --- a/client/public/locales/ja/translation.json +++ b/client/public/locales/ja/translation.json @@ -7,11 +7,26 @@ "confirm": "この記事を削除してもよろしいですか?", "title": "記事の削除" }, + "search": { + "placeholder": "キーワードを入力してください", + "title": "検索", + "title$keyword": "検索: {{keyword}}" + }, "title": "記事", + "top": { + "confirm": "この記事をトップにしてもよろしいですか?", + "success": "トップに成功しました", + "title": "トップ" + }, "total_short$count_one": "{{count}} 記事", "total_short$count_other": "{{count}} 記事", "total$count_one": "合計 {{count}} 記事", - "total$count_other": "合計 {{count}} 記事" + "total$count_other": "合計 {{count}} 記事", + "untop": { + "confirm": "この記事のトップを解除してもよろしいですか?", + "success": "トップ解除に成功しました", + "title": "トップ解除" + } }, "avatar": { "url": "アバターURL" @@ -162,8 +177,14 @@ "timeline": "タイムライン", "title": "タイトル", "title_empty": "タイトルは空にできません", + "top": { + "title": "キャッシュをクリア" + }, "unlisted": "リストされていない", "untitled": "無題", + "untop": { + "title": "トップ解除" + }, "update": { "success": "更新に成功しました", "title": "更新" diff --git a/client/public/locales/zh/translation.json b/client/public/locales/zh/translation.json index 3f2d6b97..8542b018 100644 --- a/client/public/locales/zh/translation.json +++ b/client/public/locales/zh/translation.json @@ -7,11 +7,26 @@ "confirm": "确定删除这篇文章吗?", "title": "删除文章" }, + "search": { + "placeholder": "请输入关键字", + "title": "搜索", + "title$keyword": "搜索:{{keyword}}" + }, "title": "文章", + "top": { + "confirm": "确定置顶这篇文章吗?", + "success": "置顶成功", + "title": "置顶" + }, "total_short$count_one": "{{count}} 篇", "total_short$count_other": "{{count}} 篇", "total$count_one": "共有 {{count}} 篇文章", - "total$count_other": "共有 {{count}} 篇文章" + "total$count_other": "共有 {{count}} 篇文章", + "untop": { + "confirm": "确定取消置顶这篇文章吗?", + "success": "取消置顶成功", + "title": "取消置顶" + } }, "avatar": { "url": "头像 URL" @@ -162,8 +177,14 @@ "timeline": "时间轴", "title": "标题", "title_empty": "标题不能为空", + "top": { + "title": "置顶" + }, "unlisted": "未列出", "untitled": "未列出", + "untop": { + "title": "取消置顶" + }, "update": { "success": "更新成功", "title": "更新" diff --git a/client/src/App.tsx b/client/src/App.tsx index a45e4f63..f6819ef6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import { ClientConfigContext, ConfigWrapper, defaultClientConfig } from './state import { Profile, ProfileContext } from './state/profile' import { headersWithAuth } from './utils/auth' import { tryInt } from './utils/int' +import { SearchPage } from './page/search.tsx' function App() { const ref = useRef(false) @@ -90,6 +91,12 @@ function App() { }} + + {params => { + return () + }} + + diff --git a/client/src/base.css b/client/src/base.css index 5188206a..8d9dbcf0 100644 --- a/client/src/base.css +++ b/client/src/base.css @@ -13,6 +13,10 @@ .bg-active { @apply active:bg-neutral-200 dark:active:bg-neutral-600; } + + .bg-hover { + @apply hover:bg-neutral-200 dark:hover:bg-neutral-600; + } .shadow-light { @apply shadow-neutral-200/30 dark:shadow-black/10; diff --git a/client/src/components/feed_card.tsx b/client/src/components/feed_card.tsx index a3b70d39..843721f6 100644 --- a/client/src/components/feed_card.tsx +++ b/client/src/components/feed_card.tsx @@ -2,7 +2,14 @@ import { Link } from "wouter"; import { useTranslation } from "react-i18next"; import { timeago } from "../utils/timeago"; import { HashTag } from "./hashtag"; -export function FeedCard({ id, title, avatar, draft, listed, summary, hashtags, createdAt, updatedAt }: { id: string, avatar?: string, draft?: number, listed?: number, title: string, summary: string, hashtags: { id: number, name: string }[], createdAt: Date, updatedAt: Date }) { +export function FeedCard({ id, title, avatar, draft, listed, top, summary, hashtags, createdAt, updatedAt }: + { + 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 ( <> @@ -26,6 +33,9 @@ export function FeedCard({ id, title, avatar, draft, listed, summary, hashtags, } {draft === 1 && 草稿} {listed === 0 && 未列出} + {top === 1 && + 置顶 + }

{summary} diff --git a/client/src/components/header.tsx b/client/src/components/header.tsx index a09f5efe..2f6aa2e9 100644 --- a/client/src/components/header.tsx +++ b/client/src/components/header.tsx @@ -1,11 +1,14 @@ import { useContext, useState } from "react"; import { useTranslation } from "react-i18next"; +import ReactModal from "react-modal"; import Popup from "reactjs-popup"; import { removeCookie } from "typescript-cookie"; import { Link, useLocation } from "wouter"; import { oauth_url } from "../main"; import { Profile, ProfileContext } from "../state/profile"; +import { Button } from "./button"; import { Icon } from "./icon"; +import { Input } from "./input"; import { Padding } from "./padding"; @@ -54,6 +57,7 @@ export function Header({ children }: { children?: React.ReactNode }) {

+
@@ -146,6 +150,7 @@ function Menu() { >
+
@@ -210,4 +215,61 @@ function LanguageSwitch({ className }: { className?: string }) {
) +} + +function SearchButton({ className, onClose }: { className?: string, onClose?: () => void }) { + const { t } = useTranslation() + const [isOpened, setIsOpened] = useState(false); + const [_, setLocation] = useLocation() + const [value, setValue] = useState('') + const label = t('article.search.title') + const onSearch = () => { + const key = `${encodeURIComponent(value)}` + setTimeout(() => { + setIsOpened(false) + onClose?.() + }, 100) + if (value.length != 0) + setLocation(`/search/${key}`) + } + return (
+ + setIsOpened(false)} + > +
+ +
+
+
+ ) } \ No newline at end of file diff --git a/client/src/components/input.tsx b/client/src/components/input.tsx index 637c0ba1..9a0eba57 100644 --- a/client/src/components/input.tsx +++ b/client/src/components/input.tsx @@ -1,14 +1,23 @@ -export function Input({ value, setValue, className, placeholder }: { value: string, className?: string, placeholder: string, id?: number, setValue: (v: string) => void }) { + +export function Input({ autofocus, value, setValue, className, placeholder, onSubmit }: + { autofocus?: boolean, value: string, className?: string, placeholder: string, id?: number, setValue: (v: string) => void, onSubmit?: () => void }) { return ( { + if (event.key === 'Enter' && onSubmit) { + onSubmit() + } + }} onChange={(event) => { setValue(event.target.value) }} - className={'bg-secondary w-full py-2 px-4 rounded-xl bg-w t-primary ' + className} /> + className={'focus-visible:outline-none bg-secondary focus-visible:outline-theme w-full py-2 px-4 rounded-xl bg-w t-primary ' + className} /> ) } -export function Checkbox({ value, setValue, className, placeholder }: { value: boolean, className?: string, placeholder: string, id: string, setValue: React.Dispatch> }) { +export function Checkbox({ value, setValue, className, placeholder }: + { value: boolean, className?: string, placeholder: string, id: string, setValue: React.Dispatch> }) { return ( JSX.Elemen const [_, setLocation] = useLocation(); const { showAlert, AlertUI } = useAlert(); const { showConfirm, ConfirmUI } = useConfirm(); + const [top, setTop] = useState(0); const config = useContext(ClientConfigContext); const counterEnabled = config.get('counter.enabled'); function deleteFeed() { @@ -69,6 +70,31 @@ export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Elemen }); }) } + function topFeed() { + const topNew = top === 0 ? 1 : 0; + // Confirm + showConfirm( + topNew === 1 ? t("article.top.title") : t("article.untop.title"), + topNew === 1 ? t("article.top.confirm") : t("article.untop.confirm"), + () => { + if (!feed) return; + client + .feed.top({ id: feed.id }) + .post({ + top: topNew, + }, { + headers: headersWithAuth(), + }) + .then(({ error }) => { + if (error) { + showAlert(error.value as string); + } else { + showAlert(topNew === 1 ? t("article.top.success") : t("article.untop.success")); + setTop(topNew); + } + }); + }) + } useEffect(() => { if (ref.current == id) return; setFeed(undefined); @@ -85,6 +111,7 @@ export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Elemen } else if (data && typeof data !== "string") { setTimeout(() => { setFeed(data); + setTop(data.top); // Extract head image const img_reg = /!\[.*?\]\((.*?)\)/; const img_match = img_reg.exec(data.content); @@ -196,17 +223,24 @@ export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Elemen
{profile?.permission && (
+ - + diff --git a/client/src/page/search.tsx b/client/src/page/search.tsx new file mode 100644 index 00000000..11965b9a --- /dev/null +++ b/client/src/page/search.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef, useState } from "react" +import { Helmet } from 'react-helmet' +import { useTranslation } from "react-i18next" +import { Link, useSearch } from "wouter" +import { FeedCard } from "../components/feed_card" +import { Waiting } from "../components/loading" +import { client } from "../main" +import { headersWithAuth } from "../utils/auth" +import { siteName } from "../utils/constants" +import { tryInt } from "../utils/int" + +type FeedsData = { + size: number, + data: any[], + hasNext: boolean +} + +export function SearchPage({ keyword }: { keyword: string }) { + const { t } = useTranslation() + const query = new URLSearchParams(useSearch()); + const [status, setStatus] = useState<'loading' | 'idle'>('idle') + const [feeds, setFeeds] = useState() + const page = tryInt(1, query.get("page")) + const limit = tryInt(10, query.get("limit"), process.env.PAGE_SIZE) + const ref = useRef("") + function fetchFeeds() { + if (!keyword) return + client.search({ keyword }).get({ + query: { + page: page, + limit: limit + }, + headers: headersWithAuth() + }).then(({ data }) => { + if (data && typeof data != 'string') { + setFeeds(data) + setStatus('idle') + } + }) + } + useEffect(() => { + const key = `${page} ${limit} ${keyword}` + if (ref.current == key) return + setStatus('loading') + fetchFeeds() + ref.current = key + }, [page, limit, keyword]) + const title = t('article.search.title$keyword', { keyword }) + return ( + <> + + {`${title} - ${process.env.NAME}`} + + + + + + + +
+
+

+ {t('article.search.title')} +

+
+

+ {t('article.total$count', { count: feeds?.size })} +

+
+
+ +
+ {feeds?.data.map(({ id, ...feed }: any) => ( + + ))} +
+
+ {page > 1 && + + {t('previous')} + + } +
+ {feeds?.hasNext && + + {t('next')} + + } +
+ +
+
+ + ) +} diff --git a/client/src/page/writing.tsx b/client/src/page/writing.tsx index d5bf91bb..99504e68 100644 --- a/client/src/page/writing.tsx +++ b/client/src/page/writing.tsx @@ -434,8 +434,7 @@ export function WritingPage({ id }: { id?: number }) { className="" value={content} // onPaste={handlePaste} - onChange={(data, e) => { - console.log(e) + onChange={(data, _) => { cache.set("content", data ?? ""); setContent(data ?? ""); }} diff --git a/server/sql/0002.sql b/server/sql/0002.sql new file mode 100644 index 00000000..038e620e --- /dev/null +++ b/server/sql/0002.sql @@ -0,0 +1 @@ +ALTER TABLE `feeds` ADD `top` integer DEFAULT 0 NOT NULL; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 992b776b..24fe34d8 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -12,6 +12,7 @@ export const feeds = sqliteTable("feeds", { content: text("content").notNull(), listed: integer("listed").default(1).notNull(), draft: integer("draft").default(1).notNull(), + top: integer("top").default(0).notNull(), uid: integer("uid").references(() => users.id).notNull(), createdAt: created_at, updatedAt: updated_at, diff --git a/server/src/services/feed.ts b/server/src/services/feed.ts index 9ba61b30..df0bf2ea 100644 --- a/server/src/services/feed.ts +++ b/server/src/services/feed.ts @@ -1,4 +1,4 @@ -import { and, count, desc, eq, or } from "drizzle-orm"; +import { and, count, desc, eq, like, or } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { XMLParser } from "fast-xml-parser"; import html2md from 'html-to-md'; @@ -56,7 +56,7 @@ export function FeedService() { columns: { id: true, username: true, avatar: true } } }, - orderBy: [desc(feeds.createdAt), desc(feeds.updatedAt)], + orderBy: [desc(feeds.top), desc(feeds.createdAt), desc(feeds.updatedAt)], offset: page_num * limit_num, limit: limit_num + 1, })).map(({ content, hashtags, summary, ...other }) => { @@ -220,7 +220,7 @@ export function FeedService() { set, uid, params: { id }, - body: { title, listed, content, summary, alias, draft, tags, createdAt } + body: { title, listed, content, summary, alias, draft, top, tags, createdAt } }) => { const id_num = parseInt(id); const feed = await db.query.feeds.findFirst({ @@ -239,6 +239,7 @@ export function FeedService() { content, summary, alias, + top, listed: listed ? 1 : 0, draft: draft ? 1 : 0, createdAt: createdAt ? new Date(createdAt) : undefined, @@ -258,7 +259,37 @@ export function FeedService() { listed: t.Boolean(), draft: t.Optional(t.Boolean()), createdAt: t.Optional(t.Date()), - tags: t.Optional(t.Array(t.String())) + tags: t.Optional(t.Array(t.String())), + top: t.Optional(t.Integer()) + }) + }) + .post('/top/:id', async ({ + admin, + set, + uid, + params: { id }, + body: { top } + }) => { + const id_num = parseInt(id); + const feed = await db.query.feeds.findFirst({ + where: eq(feeds.id, id_num) + }); + if (!feed) { + set.status = 404; + return 'Not found'; + } + if (feed.uid !== uid && !admin) { + set.status = 403; + return 'Permission denied'; + } + await db.update(feeds).set({ + top + }).where(eq(feeds.id, feed.id)); + await clearFeedCache(feed.id, null, null); + return 'Updated'; + }, { + body: t.Object({ + top: t.Integer() }) }) .delete('/:id', async ({ admin, set, uid, params: { id } }) => { @@ -279,6 +310,74 @@ export function FeedService() { return 'Deleted'; }) ) + .get('/search/:keyword', async ({ admin, params: { keyword }, query: { page, limit } }) => { + keyword = decodeURI(keyword); + const cache = PublicCache(); + const page_num = (page ? page > 0 ? page : 1 : 1) - 1; + const limit_num = limit ? +limit > 50 ? 50 : +limit : 20; + if (keyword === undefined || keyword.trim().length === 0) { + return { + size: 0, + data: [], + hasNext: false + } + } + const cacheKey = `search_${keyword}`; + const searchKeyword = `%${keyword}%`; + const feed_list = (await cache.getOrSet(cacheKey, () => db.query.feeds.findMany({ + where: or(like(feeds.title, searchKeyword), + like(feeds.content, searchKeyword), + like(feeds.summary, searchKeyword), + like(feeds.alias, searchKeyword)), + columns: admin ? undefined : { + draft: false, + listed: false + }, + with: { + hashtags: { + columns: {}, + with: { + hashtag: { + columns: { id: true, name: true } + } + } + }, user: { + columns: { id: true, username: true, avatar: true } + } + }, + orderBy: [desc(feeds.createdAt), desc(feeds.updatedAt)], + }))).map(({ content, hashtags, summary, ...other }) => { + return { + summary: summary.length > 0 ? summary : content.length > 100 ? content.slice(0, 100) : content, + hashtags: hashtags.map(({ hashtag }) => hashtag), + ...other + } + }); + if (feed_list.length <= page_num * limit_num) { + return { + size: feed_list.length, + data: [], + hasNext: false + } + } else if (feed_list.length <= page_num * limit_num + limit_num) { + return { + size: feed_list.length, + data: feed_list.slice(page_num * limit_num), + hasNext: false + } + } else { + return { + size: feed_list.length, + data: feed_list.slice(page_num * limit_num, page_num * limit_num + limit_num), + hasNext: true + } + } + }, { + query: t.Object({ + page: t.Optional(t.Numeric()), + limit: t.Optional(t.Numeric()), + }) + }) .post('wp', async ({ set, admin, body: { data } }) => { if (!admin) { set.status = 403; @@ -378,6 +477,7 @@ type FeedItem = { async function clearFeedCache(id: number, alias: string | null, newAlias: string | null) { const cache = PublicCache() await cache.deletePrefix('feeds_'); + await cache.deletePrefix('search_'); await cache.delete(`feed_${id}`, false); if (alias === newAlias) return; if (alias) diff --git a/server/src/services/friends.ts b/server/src/services/friends.ts index 689ba2e8..4217b761 100644 --- a/server/src/services/friends.ts +++ b/server/src/services/friends.ts @@ -16,10 +16,8 @@ export function FriendService() { .group('/friend', (group) => group.get('/', async ({ admin, uid }) => { const friend_list = await (admin ? db.query.friends.findMany() : db.query.friends.findMany({ where: eq(friends.accepted, 1) })); - console.log(friend_list); const uid_num = parseInt(uid); const apply_list = await db.query.friends.findFirst({ where: eq(friends.uid, uid_num ?? null) }); - console.log(apply_list); return { friend_list, apply_list }; }) .post('/', async ({ admin, uid, set, body: { name, desc, avatar, url } }) => {