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 } }) => {