Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12a953e960 | |||
| edd2867a47 | |||
| 70ab5ef059 | |||
| adb151dc53 | |||
| 756b9f5d1c | |||
| 772a68a87b | |||
| b75e4bcdd9 | |||
| b7ecfa5e35 | |||
| fc866e23f9 |
@@ -11,3 +11,9 @@ memos
|
|||||||
.dockerignore
|
.dockerignore
|
||||||
docs/
|
docs/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/memos_prod.db
|
||||||
|
/memos_prod.db-shm
|
||||||
|
/memos_prod.db-wal
|
||||||
|
/assets/
|
||||||
|
/.thumbnail_cache/
|
||||||
|
/.motion_cache/
|
||||||
|
|||||||
@@ -31,3 +31,11 @@ dist
|
|||||||
|
|
||||||
# Frontend test coverage output (Vitest + @vitest/coverage-v8).
|
# Frontend test coverage output (Vitest + @vitest/coverage-v8).
|
||||||
web/coverage/
|
web/coverage/
|
||||||
|
|
||||||
|
# Dev
|
||||||
|
/memos_prod.db
|
||||||
|
/memos_prod.db-shm
|
||||||
|
/memos_prod.db-wal
|
||||||
|
/assets/
|
||||||
|
/.thumbnail_cache/
|
||||||
|
/.motion_cache/
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
# anian-memos
|
||||||
|
|
||||||
|
> 基于原作者v0.29.1代码修改
|
||||||
|
|
||||||
|
#### v0.29.1-4
|
||||||
|
1. 修复缓存图片无法删除
|
||||||
|
2. 修改安全策略避免视频无法播放
|
||||||
|
|
||||||
|
#### v0.29.1-3
|
||||||
|
1. 修改设置页面commit基础链接
|
||||||
|
2. 优化图标样式、备忘录菜单跳转
|
||||||
|
3. 归档页面允许设置表情
|
||||||
|
|
||||||
|
#### v0.29.1-2
|
||||||
|
1. 置顶内容新增自动折叠;置顶效果及图标改为全局显示
|
||||||
|
2. 归档页面新增(取消)置顶、编辑、复制按钮
|
||||||
|
3. 权限图标改为全局显示
|
||||||
|
4. 修复中文目录锚点无效;优化目录显示效果
|
||||||
|
5. 备忘录被删除时显式删除其分享链接
|
||||||
|
|
||||||
|
#### v0.29.1-1
|
||||||
|
1. 修改默认配置
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<p><b>Featured Sponsors</b></p>
|
<p><b>Featured Sponsors</b></p>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ func EmailConfigFromInstanceSetting(setting *storepb.InstanceNotificationSetting
|
|||||||
func NewTestEmailMessage(recipientEmail, replyTo string) *email.Message {
|
func NewTestEmailMessage(recipientEmail, replyTo string) *email.Message {
|
||||||
return &email.Message{
|
return &email.Message{
|
||||||
To: []string{recipientEmail},
|
To: []string{recipientEmail},
|
||||||
Subject: "[Memos] Test email",
|
Subject: "[阿年的备忘录] 测试邮件",
|
||||||
Body: "This is a test email from your Memos notification settings.",
|
Body: "这是一封测试邮件,说明通知邮件设置已正确配置。",
|
||||||
ReplyTo: replyTo,
|
ReplyTo: replyTo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,19 +161,17 @@ func (d *EmailDispatcher) buildMemoCommentEmailMessage(message *storepb.InboxMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
body := []string{
|
body := []string{
|
||||||
fmt.Sprintf("Hi %s,", displayNameForEmail(receiver)),
|
fmt.Sprintf("%s,你好:", displayNameForEmail(receiver)),
|
||||||
"",
|
"",
|
||||||
fmt.Sprintf("%s commented on your memo.", senderName),
|
fmt.Sprintf("%s 评论了你的备忘录。", senderName),
|
||||||
"",
|
"",
|
||||||
"Open in Memos:",
|
"前往查看:",
|
||||||
url,
|
url,
|
||||||
"",
|
|
||||||
"You are receiving this because you own this memo.",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &email.Message{
|
return &email.Message{
|
||||||
To: []string{receiver.Email},
|
To: []string{receiver.Email},
|
||||||
Subject: fmt.Sprintf("[Memos] %s commented on your memo", senderName),
|
Subject: fmt.Sprintf("[阿年的备忘录] %s评论了你的备忘录", senderName),
|
||||||
Body: strings.Join(body, "\n"),
|
Body: strings.Join(body, "\n"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -193,19 +191,17 @@ func (d *EmailDispatcher) buildMemoMentionEmailMessage(message *storepb.InboxMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
body := []string{
|
body := []string{
|
||||||
fmt.Sprintf("Hi %s,", displayNameForEmail(receiver)),
|
fmt.Sprintf("%s,你好:", displayNameForEmail(receiver)),
|
||||||
"",
|
"",
|
||||||
fmt.Sprintf("%s mentioned you in a memo.", senderName),
|
fmt.Sprintf("%s 在备忘录中提到了你。", senderName),
|
||||||
"",
|
"",
|
||||||
"Open in Memos:",
|
"前往查看:",
|
||||||
url,
|
url,
|
||||||
"",
|
|
||||||
"You are receiving this because you were mentioned in this memo.",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &email.Message{
|
return &email.Message{
|
||||||
To: []string{receiver.Email},
|
To: []string{receiver.Email},
|
||||||
Subject: fmt.Sprintf("[Memos] %s mentioned you in a memo", senderName),
|
Subject: fmt.Sprintf("[阿年的备忘录] %s在备忘录中提到了你", senderName),
|
||||||
Body: strings.Join(body, "\n"),
|
Body: strings.Join(body, "\n"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ func getDeleteUserAttachmentStorageSetting(ctx context.Context, stores *store.St
|
|||||||
|
|
||||||
func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting {
|
func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting {
|
||||||
return &v1pb.UserSetting_GeneralSetting{
|
return &v1pb.UserSetting_GeneralSetting{
|
||||||
Locale: "en",
|
Locale: "zh-Hans",
|
||||||
MemoVisibility: "PRIVATE",
|
MemoVisibility: "PRIVATE",
|
||||||
Theme: "",
|
Theme: "",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ func (s *FileServerService) getThumbnailPath(attachment *store.Attachment) (stri
|
|||||||
if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil {
|
if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil {
|
||||||
return "", errors.Wrap(err, "failed to create thumbnail cache folder")
|
return "", errors.Wrap(err, "failed to create thumbnail cache folder")
|
||||||
}
|
}
|
||||||
filename := fmt.Sprintf("%s.v2.jpeg", attachment.UID)
|
filename := fmt.Sprintf("%s.jpeg", attachment.UID)
|
||||||
return filepath.Join(cacheFolder, filename), nil
|
return filepath.Join(cacheFolder, filename), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +743,7 @@ func setSecurityHeaders(c *echo.Context) {
|
|||||||
h := c.Response().Header()
|
h := c.Response().Header()
|
||||||
h.Set("X-Content-Type-Options", "nosniff")
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline';")
|
h.Set("Content-Security-Policy", "default-src 'self'; style-src 'unsafe-inline';")
|
||||||
}
|
}
|
||||||
|
|
||||||
// setMediaHeaders sets headers for media file responses.
|
// setMediaHeaders sets headers for media file responses.
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ func TestServeAttachmentFile_SVGThumbnailServedAsImageWithSecurityHeaders(t *tes
|
|||||||
require.Equal(t, "image/svg+xml", rec.Header().Get("Content-Type"))
|
require.Equal(t, "image/svg+xml", rec.Header().Get("Content-Type"))
|
||||||
require.Empty(t, rec.Header().Get("Content-Disposition"))
|
require.Empty(t, rec.Header().Get("Content-Disposition"))
|
||||||
require.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options"))
|
require.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options"))
|
||||||
require.Equal(t, "default-src 'none'; style-src 'unsafe-inline';", rec.Header().Get("Content-Security-Policy"))
|
require.Equal(t, "default-src 'self'; style-src 'unsafe-inline';", rec.Header().Get("Content-Security-Policy"))
|
||||||
require.Equal(t, svgContent, rec.Body.Bytes())
|
require.Equal(t, svgContent, rec.Body.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ func (*RSSService) generateItemTitle(content string) string {
|
|||||||
|
|
||||||
// If title is empty, use a default
|
// If title is empty, use a default
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "Memo"
|
title = "阿年的备忘录"
|
||||||
}
|
}
|
||||||
|
|
||||||
return title
|
return title
|
||||||
@@ -416,9 +416,9 @@ func getRSSHeading(ctx context.Context, stores *store.Store) (RSSHeading, error)
|
|||||||
}
|
}
|
||||||
if settings == nil || settings.CustomProfile == nil {
|
if settings == nil || settings.CustomProfile == nil {
|
||||||
return RSSHeading{
|
return RSSHeading{
|
||||||
Title: "Memos",
|
Title: "阿年的备忘录",
|
||||||
Description: "An open source, lightweight note-taking service. Easily capture and share your great thoughts.",
|
Description: "一日不见 如隔三秋",
|
||||||
Language: "en-us",
|
Language: "zh-Hans",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
customProfile := settings.CustomProfile
|
customProfile := settings.CustomProfile
|
||||||
@@ -426,6 +426,6 @@ func getRSSHeading(ctx context.Context, stores *store.Store) (RSSHeading, error)
|
|||||||
return RSSHeading{
|
return RSSHeading{
|
||||||
Title: customProfile.Title,
|
Title: customProfile.Title,
|
||||||
Description: customProfile.Description,
|
Description: customProfile.Description,
|
||||||
Language: "en-us",
|
Language: "zh-Hans",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ func (d *DB) DeleteMemoShare(ctx context.Context, delete *store.DeleteMemoShare)
|
|||||||
if delete.UID != nil {
|
if delete.UID != nil {
|
||||||
where, args = append(where, "`uid` = ?"), append(args, *delete.UID)
|
where, args = append(where, "`uid` = ?"), append(args, *delete.UID)
|
||||||
}
|
}
|
||||||
|
if delete.MemoID != nil {
|
||||||
|
where, args = append(where, "`memo_id` = ?"), append(args, *delete.MemoID)
|
||||||
|
}
|
||||||
_, err := d.db.ExecContext(ctx, "DELETE FROM `memo_share` WHERE "+strings.Join(where, " AND "), args...)
|
_, err := d.db.ExecContext(ctx, "DELETE FROM `memo_share` WHERE "+strings.Join(where, " AND "), args...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ func (d *DB) DeleteMemoShare(ctx context.Context, delete *store.DeleteMemoShare)
|
|||||||
if delete.UID != nil {
|
if delete.UID != nil {
|
||||||
where, args = append(where, "uid = "+placeholder(len(args)+1)), append(args, *delete.UID)
|
where, args = append(where, "uid = "+placeholder(len(args)+1)), append(args, *delete.UID)
|
||||||
}
|
}
|
||||||
|
if delete.MemoID != nil {
|
||||||
|
where, args = append(where, "memo_id = "+placeholder(len(args)+1)), append(args, *delete.MemoID)
|
||||||
|
}
|
||||||
_, err := d.db.ExecContext(ctx, "DELETE FROM memo_share WHERE "+strings.Join(where, " AND "), args...)
|
_, err := d.db.ExecContext(ctx, "DELETE FROM memo_share WHERE "+strings.Join(where, " AND "), args...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ func (d *DB) DeleteMemoShare(ctx context.Context, delete *store.DeleteMemoShare)
|
|||||||
if delete.UID != nil {
|
if delete.UID != nil {
|
||||||
where, args = append(where, "`uid` = ?"), append(args, *delete.UID)
|
where, args = append(where, "`uid` = ?"), append(args, *delete.UID)
|
||||||
}
|
}
|
||||||
|
if delete.MemoID != nil {
|
||||||
|
where, args = append(where, "`memo_id` = ?"), append(args, *delete.MemoID)
|
||||||
|
}
|
||||||
_, err := d.db.ExecContext(ctx, "DELETE FROM `memo_share` WHERE "+strings.Join(where, " AND "), args...)
|
_, err := d.db.ExecContext(ctx, "DELETE FROM `memo_share` WHERE "+strings.Join(where, " AND "), args...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ func (s *Store) DeleteMemo(ctx context.Context, delete *DeleteMemo) error {
|
|||||||
if err := s.driver.DeleteMemoRelation(ctx, &DeleteMemoRelation{RelatedMemoID: &delete.ID}); err != nil {
|
if err := s.driver.DeleteMemoRelation(ctx, &DeleteMemoRelation{RelatedMemoID: &delete.ID}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Clean up share links for this memo. SQLite runs with foreign keys disabled,
|
||||||
|
// so it cannot rely on ON DELETE CASCADE for memo_share rows.
|
||||||
|
if err := s.driver.DeleteMemoShare(ctx, &DeleteMemoShare{MemoID: &delete.ID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Clean up attachments linked to this memo.
|
// Clean up attachments linked to this memo.
|
||||||
attachments, err := s.ListAttachments(ctx, &FindAttachment{MemoID: &delete.ID})
|
attachments, err := s.ListAttachments(ctx, &FindAttachment{MemoID: &delete.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type FindMemoShare struct {
|
|||||||
type DeleteMemoShare struct {
|
type DeleteMemoShare struct {
|
||||||
ID *int32
|
ID *int32
|
||||||
UID *string
|
UID *string
|
||||||
|
MemoID *int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateMemoShare creates a new share grant.
|
// CreateMemoShare creates a new share grant.
|
||||||
|
|||||||
@@ -112,10 +112,35 @@ func TestDeleteMemoStore(t *testing.T) {
|
|||||||
memo, err := ts.CreateMemo(ctx, memoCreate)
|
memo, err := ts.CreateMemo(ctx, memoCreate)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, memoCreate.Content, memo.Content)
|
require.Equal(t, memoCreate.Content, memo.Content)
|
||||||
|
otherMemo, err := ts.CreateMemo(ctx, &store.Memo{
|
||||||
|
UID: "test-other-memo",
|
||||||
|
CreatorID: user.ID,
|
||||||
|
Content: "other test content",
|
||||||
|
Visibility: store.Public,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
share, err := ts.CreateMemoShare(ctx, &store.MemoShare{
|
||||||
|
UID: "test-delete-memo-share",
|
||||||
|
MemoID: memo.ID,
|
||||||
|
CreatorID: user.ID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
keptShare, err := ts.CreateMemoShare(ctx, &store.MemoShare{
|
||||||
|
UID: "test-keep-memo-share",
|
||||||
|
MemoID: otherMemo.ID,
|
||||||
|
CreatorID: user.ID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
err = ts.DeleteMemo(ctx, &store.DeleteMemo{
|
err = ts.DeleteMemo(ctx, &store.DeleteMemo{
|
||||||
ID: memo.ID,
|
ID: memo.ID,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
deletedShare, err := ts.GetMemoShare(ctx, &store.FindMemoShare{ID: &share.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, deletedShare)
|
||||||
|
keptShareAfterDelete, err := ts.GetMemoShare(ctx, &store.FindMemoShare{ID: &keptShare.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, keptShareAfterDelete)
|
||||||
ts.Close()
|
ts.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-Hans">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<!-- memos.metadata.head -->
|
<!-- memos.metadata.head -->
|
||||||
<title>Memos</title>
|
<title>阿年的备忘录</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="text-base w-full min-h-svh">
|
<body class="text-base w-full min-h-svh">
|
||||||
<div id="root" class="relative w-full min-h-full"></div>
|
<div id="root" class="relative w-full min-h-full"></div>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Memos",
|
"name": "阿年的备忘录",
|
||||||
"short_name": "Memos",
|
"short_name": "阿年的备忘录",
|
||||||
"description": "An open-source, self-hosted note-taking tool. Capture thoughts instantly. Own them completely.",
|
"description": "一日不见 如隔三秋",
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
|
|||||||
baseClasses,
|
baseClasses,
|
||||||
intensityClass,
|
intensityClass,
|
||||||
getCalendarCellStateClass(day),
|
getCalendarCellStateClass(day),
|
||||||
isInteractive ? "cursor-pointer hover:bg-muted/40 hover:border-border/30" : "cursor-default",
|
day.isToday && "underline decoration-1 underline-offset-4",
|
||||||
|
isInteractive ? "cursor-pointer hover:bg-muted/40 hover:border-border/30 hover:text-foreground" : "cursor-default",
|
||||||
);
|
);
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
ArchiveRestoreIcon,
|
ArchiveRestoreIcon,
|
||||||
BookmarkMinusIcon,
|
BookmarkMinusIcon,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
CheckCheckIcon,
|
CheckCheckIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
Edit3Icon,
|
Edit3Icon,
|
||||||
|
EyeIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
ListChecksIcon,
|
ListChecksIcon,
|
||||||
@@ -32,7 +34,7 @@ import { useMemoActionHandlers } from "./hooks";
|
|||||||
import type { MemoActionMenuProps } from "./types";
|
import type { MemoActionMenuProps } from "./types";
|
||||||
|
|
||||||
const MemoActionMenu = (props: MemoActionMenuProps) => {
|
const MemoActionMenu = (props: MemoActionMenuProps) => {
|
||||||
const { memo, readonly } = props;
|
const { memo, readonly, isInMemoDetailPage, onBack, onViewDetail } = props;
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
// Dialog state
|
// Dialog state
|
||||||
@@ -45,6 +47,8 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
|||||||
const canMutateTasks = !readonly && !isArchived && taskStats.total > 0;
|
const canMutateTasks = !readonly && !isArchived && taskStats.total > 0;
|
||||||
const hasOpenTasks = taskStats.completed < taskStats.total;
|
const hasOpenTasks = taskStats.completed < taskStats.total;
|
||||||
const hasCompletedTasks = taskStats.completed > 0;
|
const hasCompletedTasks = taskStats.completed > 0;
|
||||||
|
const DetailActionIcon = isInMemoDetailPage ? ArrowLeftIcon : EyeIcon;
|
||||||
|
const detailActionLabel = isInMemoDetailPage ? t("common.collapse") : t("memo.view-detail");
|
||||||
|
|
||||||
// Action handlers
|
// Action handlers
|
||||||
const {
|
const {
|
||||||
@@ -71,8 +75,13 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" sideOffset={2}>
|
<DropdownMenuContent align="end" sideOffset={2}>
|
||||||
{/* Edit actions (non-readonly, non-archived) */}
|
<DropdownMenuItem onClick={isInMemoDetailPage ? onBack : onViewDetail}>
|
||||||
{!readonly && !isArchived && (
|
<DetailActionIcon className="w-4 h-auto" />
|
||||||
|
{detailActionLabel}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{/* Edit actions (non-readonly) */}
|
||||||
|
{!readonly && (
|
||||||
<>
|
<>
|
||||||
{!isComment && (
|
{!isComment && (
|
||||||
<DropdownMenuItem onClick={handleTogglePinMemoBtnClick}>
|
<DropdownMenuItem onClick={handleTogglePinMemoBtnClick}>
|
||||||
@@ -87,8 +96,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Copy submenu (non-archived) */}
|
{/* Copy submenu */}
|
||||||
{!isArchived && (
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
<CopyIcon className="w-4 h-auto" />
|
<CopyIcon className="w-4 h-auto" />
|
||||||
@@ -105,7 +113,6 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Task submenu (writable task memos) */}
|
{/* Task submenu (writable task memos) */}
|
||||||
{canMutateTasks && (
|
{canMutateTasks && (
|
||||||
|
|||||||
@@ -4,5 +4,8 @@ export interface MemoActionMenuProps {
|
|||||||
memo: Memo;
|
memo: Memo;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isInMemoDetailPage: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
onViewDetail: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const MemoCommentSection = ({ memo, comments, parentPage }: Props) => {
|
|||||||
)}
|
)}
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<div className="w-full" key={`${comment.name}-${comment.updateTime}`} id={extractMemoIdFromName(comment.name)}>
|
<div className="w-full" key={`${comment.name}-${comment.updateTime}`} id={extractMemoIdFromName(comment.name)}>
|
||||||
<MemoView memo={comment} parentPage={parentPage} showCreator compact />
|
<MemoView memo={comment} parentPage={parentPage} showCreator showVisibility compact />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { HeadingItem } from "@/utils/markdown-manipulation";
|
import type { HeadingItem } from "@/utils/markdown-manipulation";
|
||||||
|
|
||||||
@@ -12,8 +13,95 @@ const levelIndent: Record<number, string> = {
|
|||||||
4: "ml-8",
|
4: "ml-8",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Outline navigation for memo headings (h1–h4). */
|
const areStringArraysEqual = (a: string[], b: string[]) => {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((item, index) => item === b[index]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Outline navigation for memo headings (h1-h4). */
|
||||||
const MemoOutline = ({ headings }: MemoOutlineProps) => {
|
const MemoOutline = ({ headings }: MemoOutlineProps) => {
|
||||||
|
const outlineRef = useRef<HTMLElement>(null);
|
||||||
|
const itemRefs = useRef(new Map<string, HTMLAnchorElement>());
|
||||||
|
const [activeSlugs, setActiveSlugs] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const orderedSlugs = useMemo(() => headings.map((heading) => heading.slug).filter(Boolean), [headings]);
|
||||||
|
const activeSlugSet = useMemo(() => new Set(activeSlugs), [activeSlugs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveSlugs([]);
|
||||||
|
|
||||||
|
const elements = orderedSlugs
|
||||||
|
.map((slug) => document.getElementById(slug))
|
||||||
|
.filter((element): element is HTMLElement => Boolean(element));
|
||||||
|
|
||||||
|
if (elements.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationFrame = 0;
|
||||||
|
|
||||||
|
const updateActiveSlugs = () => {
|
||||||
|
animationFrame = 0;
|
||||||
|
const viewportTop = window.scrollY;
|
||||||
|
const viewportBottom = viewportTop + (window.innerHeight || document.documentElement.clientHeight);
|
||||||
|
const documentBottom = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
|
||||||
|
const headingSections = elements.map((element, index) => {
|
||||||
|
const sectionTop = element.getBoundingClientRect().top + window.scrollY;
|
||||||
|
const nextElement = elements[index + 1];
|
||||||
|
const sectionBottom = nextElement ? nextElement.getBoundingClientRect().top + window.scrollY : documentBottom;
|
||||||
|
return { slug: element.id, top: sectionTop, bottom: sectionBottom };
|
||||||
|
});
|
||||||
|
const nextActiveSlugs = headingSections
|
||||||
|
.filter((section) => section.bottom > viewportTop && section.top < viewportBottom)
|
||||||
|
.map((section) => section.slug);
|
||||||
|
|
||||||
|
setActiveSlugs((previousSlugs) => (areStringArraysEqual(previousSlugs, nextActiveSlugs) ? previousSlugs : nextActiveSlugs));
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleUpdateActiveSlugs = () => {
|
||||||
|
if (animationFrame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animationFrame = window.requestAnimationFrame(updateActiveSlugs);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateActiveSlugs();
|
||||||
|
window.addEventListener("scroll", scheduleUpdateActiveSlugs, { passive: true });
|
||||||
|
window.addEventListener("resize", scheduleUpdateActiveSlugs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrame) {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
}
|
||||||
|
window.removeEventListener("scroll", scheduleUpdateActiveSlugs);
|
||||||
|
window.removeEventListener("resize", scheduleUpdateActiveSlugs);
|
||||||
|
};
|
||||||
|
}, [orderedSlugs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstActiveSlug = activeSlugs[0];
|
||||||
|
const outline = outlineRef.current;
|
||||||
|
const activeItem = firstActiveSlug ? itemRefs.current.get(firstActiveSlug) : undefined;
|
||||||
|
|
||||||
|
if (!outline || !activeItem || outline.scrollHeight <= outline.clientHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemTop = activeItem.offsetTop;
|
||||||
|
const itemBottom = itemTop + activeItem.offsetHeight;
|
||||||
|
const comfortableTop = outline.scrollTop + 24;
|
||||||
|
const comfortableBottom = outline.scrollTop + outline.clientHeight - 24;
|
||||||
|
|
||||||
|
if (itemTop >= comfortableTop && itemBottom <= comfortableBottom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
outline.scrollTo({
|
||||||
|
top: Math.max(0, itemTop - outline.clientHeight / 2 + activeItem.offsetHeight / 2),
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}, [activeSlugs]);
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
|
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const el = document.getElementById(slug);
|
const el = document.getElementById(slug);
|
||||||
@@ -24,27 +112,50 @@ const MemoOutline = ({ headings }: MemoOutlineProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="relative flex flex-col">
|
<nav ref={outlineRef} className="relative flex max-h-[45vh] flex-col overflow-y-auto overscroll-contain pr-1">
|
||||||
{headings.map((heading, index) => (
|
{headings.map((heading, index) => {
|
||||||
|
const isActive = activeSlugSet.has(heading.slug);
|
||||||
|
|
||||||
|
return (
|
||||||
<a
|
<a
|
||||||
key={`${heading.slug}-${index}`}
|
key={`${heading.slug}-${index}`}
|
||||||
|
ref={(element) => {
|
||||||
|
if (element) {
|
||||||
|
itemRefs.current.set(heading.slug, element);
|
||||||
|
} else {
|
||||||
|
itemRefs.current.delete(heading.slug);
|
||||||
|
}
|
||||||
|
}}
|
||||||
href={`#${heading.slug}`}
|
href={`#${heading.slug}`}
|
||||||
|
aria-current={activeSlugs[0] === heading.slug ? "location" : undefined}
|
||||||
onClick={(e) => handleClick(e, heading.slug)}
|
onClick={(e) => handleClick(e, heading.slug)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative block py-[5px] pr-1 text-[13px] leading-snug truncate",
|
"group relative block shrink-0 rounded-l-sm py-[5px] pr-1 pl-2 text-[13px] leading-snug truncate",
|
||||||
"text-muted-foreground/60 hover:text-foreground/90",
|
|
||||||
"transition-colors duration-200 ease-out",
|
"transition-colors duration-200 ease-out",
|
||||||
|
isActive ? "bg-primary/10 font-medium text-primary" : "text-muted-foreground/60 hover:text-foreground/90",
|
||||||
levelIndent[heading.level],
|
levelIndent[heading.level],
|
||||||
heading.level === 1 && "font-medium text-muted-foreground/80",
|
heading.level === 1 && !isActive && "font-medium text-muted-foreground/80",
|
||||||
)}
|
)}
|
||||||
title={heading.text}
|
title={heading.text}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1.5 bottom-1.5 left-0 w-0.5 rounded-full bg-primary transition-opacity duration-200",
|
||||||
|
isActive ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
{heading.text}
|
{heading.text}
|
||||||
<span className="absolute -bottom-px left-0 h-px w-0 bg-foreground/30 transition-all duration-200 group-hover:w-full" />
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -bottom-px left-0 h-px bg-foreground/30 transition-all duration-200",
|
||||||
|
isActive ? "w-full bg-primary/50" : "w-0 group-hover:w-full",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { State } from "@/types/proto/api/v1/common_pb";
|
|
||||||
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb";
|
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb";
|
||||||
import { useReactionGroups } from "./hooks";
|
import { useReactionGroups } from "./hooks";
|
||||||
import ReactionSelector from "./ReactionSelector";
|
import ReactionSelector from "./ReactionSelector";
|
||||||
@@ -15,7 +14,6 @@ const MemoReactionListView = (props: Props) => {
|
|||||||
const { memo: memoData, reactions } = props;
|
const { memo: memoData, reactions } = props;
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const reactionGroup = useReactionGroups(reactions);
|
const reactionGroup = useReactionGroups(reactions);
|
||||||
const readonly = memoData.state === State.ARCHIVED;
|
|
||||||
|
|
||||||
if (reactions.length === 0) {
|
if (reactions.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -26,7 +24,7 @@ const MemoReactionListView = (props: Props) => {
|
|||||||
{Array.from(reactionGroup).map(([reactionType, users]) => (
|
{Array.from(reactionGroup).map(([reactionType, users]) => (
|
||||||
<ReactionView key={`${reactionType.toString()} ${users.length}`} memo={memoData} reactionType={reactionType} users={users} />
|
<ReactionView key={`${reactionType.toString()} ${users.length}`} memo={memoData} reactionType={reactionType} users={users} />
|
||||||
))}
|
))}
|
||||||
{!readonly && currentUser && <ReactionSelector memo={memoData} />}
|
{currentUser && <ReactionSelector memo={memoData} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { State } from "@/types/proto/api/v1/common_pb";
|
|
||||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||||
import { formatReactionTooltip, useReactionActions } from "./hooks";
|
import { formatReactionTooltip, useReactionActions } from "./hooks";
|
||||||
@@ -16,16 +15,15 @@ const ReactionView = (props: Props) => {
|
|||||||
const { memo, reactionType, users } = props;
|
const { memo, reactionType, users } = props;
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
|
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
|
||||||
const readonly = memo.state === State.ARCHIVED;
|
|
||||||
|
|
||||||
const { handleReactionClick } = useReactionActions({ memo });
|
const { handleReactionClick } = useReactionActions({ memo });
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!currentUser || readonly) return;
|
if (!currentUser) return;
|
||||||
handleReactionClick(reactionType);
|
handleReactionClick(reactionType);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isClickable = currentUser && !readonly;
|
const isClickable = Boolean(currentUser);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
|||||||
const creator = useUser(memoData.creator).data;
|
const creator = useUser(memoData.creator).data;
|
||||||
const isArchived = memoData.state === State.ARCHIVED;
|
const isArchived = memoData.state === State.ARCHIVED;
|
||||||
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
|
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
|
||||||
const parentPage = parentPageProp || "/";
|
const location = useLocation();
|
||||||
|
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/");
|
||||||
|
const currentPage = `${location.pathname}${location.search}${location.hash}`;
|
||||||
|
const parentPage = parentPageProp || (isInMemoDetailPage ? "/" : currentPage);
|
||||||
|
|
||||||
// Blur content when any tag has blur_content enabled in the instance tag settings.
|
// Blur content when any tag has blur_content enabled in the instance tag settings.
|
||||||
const [showBlurredContent, setShowBlurredContent] = useState(false);
|
const [showBlurredContent, setShowBlurredContent] = useState(false);
|
||||||
@@ -39,8 +42,6 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
|||||||
const openEditor = useCallback(() => setShowEditor(true), []);
|
const openEditor = useCallback(() => setShowEditor(true), []);
|
||||||
const closeEditor = useCallback(() => setShowEditor(false), []);
|
const closeEditor = useCallback(() => setShowEditor(false), []);
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/");
|
|
||||||
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;
|
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
|
|||||||
content={memo.content}
|
content={memo.content}
|
||||||
onClick={handleMemoContentClick}
|
onClick={handleMemoContentClick}
|
||||||
onDoubleClick={handleMemoContentDoubleClick}
|
onDoubleClick={handleMemoContentDoubleClick}
|
||||||
compact={memo.pinned ? false : compact} // Always show full content when pinned
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
<AttachmentListView attachments={memo.attachments} onImagePreview={openPreview} />
|
<AttachmentListView attachments={memo.attachments} onImagePreview={openPreview} />
|
||||||
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
|
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { BookmarkIcon } from "lucide-react";
|
import { ArchiveIcon, BookmarkIcon } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
|
||||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { convertVisibilityToString } from "@/utils/memo";
|
import { convertVisibilityToString } from "@/utils/memo";
|
||||||
@@ -13,7 +12,6 @@ import MemoActionMenu from "../../MemoActionMenu";
|
|||||||
import { ReactionSelector } from "../../MemoReactionListView";
|
import { ReactionSelector } from "../../MemoReactionListView";
|
||||||
import UserAvatar from "../../UserAvatar";
|
import UserAvatar from "../../UserAvatar";
|
||||||
import VisibilityIcon from "../../VisibilityIcon";
|
import VisibilityIcon from "../../VisibilityIcon";
|
||||||
import { useMemoActions } from "../hooks";
|
|
||||||
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
|
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
|
||||||
import type { MemoHeaderProps } from "../types";
|
import type { MemoHeaderProps } from "../types";
|
||||||
|
|
||||||
@@ -22,14 +20,37 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
|||||||
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
||||||
|
|
||||||
const { memo, creator, currentUser, parentPage, isArchived, readonly, openEditor } = useMemoViewContext();
|
const { memo, creator, currentUser, parentPage, isArchived, readonly, openEditor } = useMemoViewContext();
|
||||||
const { createTime, updateTime, displayTime: memoDisplayTime, isDisplayingUpdatedTime, relativeTimeFormat } = useMemoViewDerived();
|
const {
|
||||||
|
createTime,
|
||||||
|
updateTime,
|
||||||
|
displayTime: memoDisplayTime,
|
||||||
|
isDisplayingUpdatedTime,
|
||||||
|
isInMemoDetailPage,
|
||||||
|
relativeTimeFormat,
|
||||||
|
} = useMemoViewDerived();
|
||||||
|
|
||||||
const navigateTo = useNavigateTo();
|
const navigateTo = useNavigateTo();
|
||||||
|
const navigate = useNavigate();
|
||||||
const handleGotoMemoDetailPage = useCallback(() => {
|
const handleGotoMemoDetailPage = useCallback(() => {
|
||||||
navigateTo(`/${memo.name}`, { state: { from: parentPage } });
|
navigateTo(`/${memo.name}`, { state: { from: parentPage } });
|
||||||
}, [memo.name, parentPage, navigateTo]);
|
}, [memo.name, parentPage, navigateTo]);
|
||||||
|
const handleBackToParentPage = useCallback(() => {
|
||||||
|
const historyState = window.history.state as { idx?: number } | null;
|
||||||
|
if (typeof historyState?.idx === "number" && historyState.idx > 0) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { unpinMemo } = useMemoActions(memo);
|
navigateTo(parentPage, { preventScrollReset: true });
|
||||||
|
}, [parentPage, navigate, navigateTo]);
|
||||||
|
const handleMemoTimeClick = useCallback(() => {
|
||||||
|
if (isInMemoDetailPage) {
|
||||||
|
handleBackToParentPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGotoMemoDetailPage();
|
||||||
|
}, [isInMemoDetailPage, handleBackToParentPage, handleGotoMemoDetailPage]);
|
||||||
|
|
||||||
const timeValue = isArchived ? (
|
const timeValue = isArchived ? (
|
||||||
memoDisplayTime?.toLocaleString(i18n.language)
|
memoDisplayTime?.toLocaleString(i18n.language)
|
||||||
@@ -52,14 +73,14 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
|||||||
<div className="w-full flex flex-row justify-between items-center gap-2">
|
<div className="w-full flex flex-row justify-between items-center gap-2">
|
||||||
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
|
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
|
||||||
{showCreator && creator ? (
|
{showCreator && creator ? (
|
||||||
<CreatorDisplay creator={creator} displayTime={displayTime} timeTooltip={timeTooltip} onGotoDetail={handleGotoMemoDetailPage} />
|
<CreatorDisplay creator={creator} displayTime={displayTime} timeTooltip={timeTooltip} onTimeClick={handleMemoTimeClick} />
|
||||||
) : (
|
) : (
|
||||||
<TimeDisplay displayTime={displayTime} timeTooltip={timeTooltip} onGotoDetail={handleGotoMemoDetailPage} />
|
<TimeDisplay displayTime={displayTime} timeTooltip={timeTooltip} onTimeClick={handleMemoTimeClick} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
|
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
|
||||||
{currentUser && !isArchived && (
|
{currentUser && (
|
||||||
<ReactionSelector
|
<ReactionSelector
|
||||||
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "block!", "block sm:hidden sm:group-hover:block")}
|
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "block!", "block sm:hidden sm:group-hover:block")}
|
||||||
memo={memo}
|
memo={memo}
|
||||||
@@ -67,7 +88,33 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showVisibility && memo.visibility !== Visibility.PRIVATE && (
|
{showPinned && memo.pinned && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="flex justify-center items-center rounded-md">
|
||||||
|
<BookmarkIcon className="w-4 h-auto text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("common.pinned")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isArchived && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="flex justify-center items-center rounded-md">
|
||||||
|
<ArchiveIcon className="w-4 h-auto text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("common.archived")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showVisibility && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<span className="flex justify-center items-center rounded-md hover:opacity-80">
|
<span className="flex justify-center items-center rounded-md hover:opacity-80">
|
||||||
@@ -80,22 +127,14 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showPinned && memo.pinned && (
|
<MemoActionMenu
|
||||||
<TooltipProvider>
|
memo={memo}
|
||||||
<Tooltip>
|
readonly={readonly}
|
||||||
<TooltipTrigger asChild>
|
isInMemoDetailPage={isInMemoDetailPage}
|
||||||
<span className="cursor-pointer">
|
onBack={handleBackToParentPage}
|
||||||
<BookmarkIcon className="w-4 h-auto text-primary" onClick={unpinMemo} />
|
onViewDetail={handleGotoMemoDetailPage}
|
||||||
</span>
|
onEdit={openEditor}
|
||||||
</TooltipTrigger>
|
/>
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("common.unpin")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MemoActionMenu memo={memo} readonly={readonly} onEdit={openEditor} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -105,10 +144,10 @@ interface CreatorDisplayProps {
|
|||||||
creator: User;
|
creator: User;
|
||||||
displayTime: React.ReactNode;
|
displayTime: React.ReactNode;
|
||||||
timeTooltip: TimeTooltipContent;
|
timeTooltip: TimeTooltipContent;
|
||||||
onGotoDetail: () => void;
|
onTimeClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, timeTooltip, onGotoDetail }) => (
|
const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, timeTooltip, onTimeClick }) => (
|
||||||
<div className="w-full flex flex-row justify-start items-center">
|
<div className="w-full flex flex-row justify-start items-center">
|
||||||
<Link className="w-auto hover:opacity-80 rounded-md transition-colors" to={`/u/${encodeURIComponent(creator.username)}`} viewTransition>
|
<Link className="w-auto hover:opacity-80 rounded-md transition-colors" to={`/u/${encodeURIComponent(creator.username)}`} viewTransition>
|
||||||
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
|
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
|
||||||
@@ -125,7 +164,7 @@ const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, t
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-auto -mt-0.5 text-xs leading-tight text-muted-foreground select-none cursor-pointer hover:opacity-80 transition-colors text-left"
|
className="w-auto -mt-0.5 text-xs leading-tight text-muted-foreground select-none cursor-pointer hover:opacity-80 transition-colors text-left"
|
||||||
onClick={onGotoDetail}
|
onClick={onTimeClick}
|
||||||
>
|
>
|
||||||
{displayTime}
|
{displayTime}
|
||||||
</button>
|
</button>
|
||||||
@@ -152,15 +191,15 @@ const TimeTooltip = ({ children, content }: { children: React.ReactElement; cont
|
|||||||
interface TimeDisplayProps {
|
interface TimeDisplayProps {
|
||||||
displayTime: React.ReactNode;
|
displayTime: React.ReactNode;
|
||||||
timeTooltip: TimeTooltipContent;
|
timeTooltip: TimeTooltipContent;
|
||||||
onGotoDetail: () => void;
|
onTimeClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimeDisplay: React.FC<TimeDisplayProps> = ({ displayTime, timeTooltip, onGotoDetail }) => (
|
const TimeDisplay: React.FC<TimeDisplayProps> = ({ displayTime, timeTooltip, onTimeClick }) => (
|
||||||
<TimeTooltip content={timeTooltip}>
|
<TimeTooltip content={timeTooltip}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-auto text-sm leading-tight text-muted-foreground select-none cursor-pointer hover:text-foreground transition-colors text-left"
|
className="w-auto text-sm leading-tight text-muted-foreground select-none cursor-pointer hover:text-foreground transition-colors text-left"
|
||||||
onClick={onGotoDetail}
|
onClick={onTimeClick}
|
||||||
>
|
>
|
||||||
{displayTime}
|
{displayTime}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { useImagePreview } from "./useImagePreview";
|
export { useImagePreview } from "./useImagePreview";
|
||||||
export { useMemoActions } from "./useMemoActions";
|
|
||||||
export { useMemoHandlers } from "./useMemoHandlers";
|
export { useMemoHandlers } from "./useMemoHandlers";
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
|
||||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
|
||||||
|
|
||||||
export const useMemoActions = (memo: Memo) => {
|
|
||||||
const { mutateAsync: updateMemo } = useUpdateMemo();
|
|
||||||
|
|
||||||
const unpinMemo = async () => {
|
|
||||||
if (!memo.pinned) return;
|
|
||||||
await updateMemo({ update: { name: memo.name, pinned: false }, updateMask: ["pinned"] });
|
|
||||||
};
|
|
||||||
|
|
||||||
return { unpinMemo };
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
function MemosLogo(props: Props) {
|
function MemosLogo(props: Props) {
|
||||||
const { collapsed } = props;
|
const { collapsed } = props;
|
||||||
const { generalSetting: instanceGeneralSetting } = useInstance();
|
const { generalSetting: instanceGeneralSetting } = useInstance();
|
||||||
const title = instanceGeneralSetting.customProfile?.title || "Memos";
|
const title = instanceGeneralSetting.customProfile?.title || "阿年的备忘录";
|
||||||
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
|
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const MobileHeader = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"sticky top-0 pt-3 pb-2 sm:pt-2 px-4 sm:px-6 sm:mb-1 bg-background bg-opacity-80 backdrop-blur-lg flex flex-row justify-between items-center w-full h-auto flex-nowrap shrink-0 z-1",
|
"sticky top-0 pt-3 pb-2 sm:pt-2 px-4 sm:px-6 sm:mb-1 bg-background bg-opacity-80 backdrop-blur-lg flex flex-row justify-between items-center w-full h-auto flex-nowrap shrink-0 z-4",
|
||||||
offsetTop > 0 && "shadow-md",
|
offsetTop > 0 && "shadow-md",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ const Navigation = (props: Props) => {
|
|||||||
title: t("common.explore"),
|
title: t("common.explore"),
|
||||||
icon: <EarthIcon className="w-6 h-auto shrink-0" />,
|
icon: <EarthIcon className="w-6 h-auto shrink-0" />,
|
||||||
};
|
};
|
||||||
const aboutNavLink: NavLinkItem = {
|
|
||||||
id: "header-about",
|
|
||||||
path: Routes.ABOUT,
|
|
||||||
title: t("common.about"),
|
|
||||||
icon: <InfoIcon className="w-6 h-auto shrink-0" />,
|
|
||||||
};
|
|
||||||
const attachmentsNavLink: NavLinkItem = {
|
const attachmentsNavLink: NavLinkItem = {
|
||||||
id: "header-attachments",
|
id: "header-attachments",
|
||||||
path: Routes.ATTACHMENTS,
|
path: Routes.ATTACHMENTS,
|
||||||
@@ -77,7 +71,7 @@ const Navigation = (props: Props) => {
|
|||||||
|
|
||||||
const primaryNavLinks: NavLinkItem[] = currentUser
|
const primaryNavLinks: NavLinkItem[] = currentUser
|
||||||
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
|
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
|
||||||
: [exploreNavLink, aboutNavLink, signInNavLink];
|
: [exploreNavLink, signInNavLink];
|
||||||
const inboxAriaLabel = unreadCount > 0 ? `${t("common.inbox")}, ${unreadCount} unread` : t("common.inbox");
|
const inboxAriaLabel = unreadCount > 0 ? `${t("common.inbox")}, ${unreadCount} unread` : t("common.inbox");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const NavigationDrawer = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { generalSetting } = useInstance();
|
const { generalSetting } = useInstance();
|
||||||
const title = generalSetting.customProfile?.title || "Memos";
|
const title = generalSetting.customProfile?.title || "阿年的备忘录";
|
||||||
const avatarUrl = generalSetting.customProfile?.logoUrl || "/full-logo.webp";
|
const avatarUrl = generalSetting.customProfile?.logoUrl || "/full-logo.webp";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const InstanceSection = () => {
|
|||||||
<SettingSection title={t("setting.system.label")}>
|
<SettingSection title={t("setting.system.label")}>
|
||||||
<SettingGroup title={t("common.basic")} description={t("setting.system.basic-description")}>
|
<SettingGroup title={t("common.basic")} description={t("setting.system.basic-description")}>
|
||||||
<SettingList>
|
<SettingList>
|
||||||
<SettingListItem label={t("setting.system.server-name")} description={instanceGeneralSetting.customProfile?.title || "Memos"}>
|
<SettingListItem label={t("setting.system.server-name")} description={instanceGeneralSetting.customProfile?.title || "阿年的备忘录"}>
|
||||||
<Button variant="outline" onClick={customizeDialog.open}>
|
<Button variant="outline" onClick={customizeDialog.open}>
|
||||||
{t("common.edit")}
|
{t("common.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const PreferencesSection = () => {
|
|||||||
const setting: UserSetting_GeneralSetting =
|
const setting: UserSetting_GeneralSetting =
|
||||||
generalSetting ||
|
generalSetting ||
|
||||||
create(UserSetting_GeneralSettingSchema, {
|
create(UserSetting_GeneralSettingSchema, {
|
||||||
locale: "en",
|
locale: "zh-Hans",
|
||||||
memoVisibility: "PRIVATE",
|
memoVisibility: "PRIVATE",
|
||||||
theme: "system",
|
theme: "system",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props)
|
|||||||
|
|
||||||
const handleRestoreButtonClick = () => {
|
const handleRestoreButtonClick = () => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
title: "Memos",
|
title: "阿年的备忘录",
|
||||||
logoUrl: "/logo.webp",
|
logoUrl: "/logo.webp",
|
||||||
description: "",
|
description: "",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -142,10 +142,6 @@ const UserMenu = (props: Props) => {
|
|||||||
<ArchiveIcon className="size-4 text-muted-foreground" />
|
<ArchiveIcon className="size-4 text-muted-foreground" />
|
||||||
{t("common.archived")}
|
{t("common.archived")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => navigateTo(Routes.ABOUT)}>
|
|
||||||
<InfoIcon className="size-4 text-muted-foreground" />
|
|
||||||
{t("common.about")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
<GlobeIcon className="size-4 text-muted-foreground" />
|
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ i18n
|
|||||||
},
|
},
|
||||||
fallbackLng: {
|
fallbackLng: {
|
||||||
...fallbacks,
|
...fallbacks,
|
||||||
...{ default: ["en"] },
|
...{ default: ["zh-Hans"] },
|
||||||
} as FallbackLng,
|
} as FallbackLng,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const AdminSignIn = () => {
|
|||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||||
<div className="w-full flex flex-row justify-center items-center mb-6">
|
<div className="w-full flex flex-row justify-center items-center mb-6">
|
||||||
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
||||||
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
|
<p className="ml-2 text-4xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "阿年的备忘录"}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="w-full text-xl font-medium text-muted-foreground">Sign in with admin accounts</p>
|
<p className="w-full text-xl font-medium text-muted-foreground">Sign in with admin accounts</p>
|
||||||
<PasswordSignInForm />
|
<PasswordSignInForm />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const Archived = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PagedMemoList
|
<PagedMemoList
|
||||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact />}
|
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned compact />}
|
||||||
listSort={listSort}
|
listSort={listSort}
|
||||||
state={State.ARCHIVED}
|
state={State.ARCHIVED}
|
||||||
orderBy={orderBy}
|
orderBy={orderBy}
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ const Explore = () => {
|
|||||||
|
|
||||||
// Get sorting logic using unified hook (no pinned sorting)
|
// Get sorting logic using unified hook (no pinned sorting)
|
||||||
const { listSort, orderBy } = useMemoSorting({
|
const { listSort, orderBy } = useMemoSorting({
|
||||||
pinnedFirst: false,
|
pinnedFirst: true,
|
||||||
state: State.NORMAL,
|
state: State.NORMAL,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PagedMemoList
|
<PagedMemoList
|
||||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact />}
|
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility showPinned compact />}
|
||||||
listSort={listSort}
|
listSort={listSort}
|
||||||
orderBy={orderBy}
|
orderBy={orderBy}
|
||||||
filter={memoFilter}
|
filter={memoFilter}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import useMediaQuery from "@/hooks/useMediaQuery";
|
|||||||
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
|
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
|
||||||
const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/";
|
const GITHUB_COMMIT_URL_PREFIX = "https://git.anian.cc/anian/memos/commit/";
|
||||||
|
|
||||||
const isCommitSha = (commit: string) => /^[0-9a-f]{7,40}$/i.test(commit);
|
const isCommitSha = (commit: string) => /^[0-9a-f]{7,40}$/i.test(commit);
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const SignIn = () => {
|
|||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||||
<div className="w-full flex flex-row justify-center items-center mb-6">
|
<div className="w-full flex flex-row justify-center items-center mb-6">
|
||||||
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
||||||
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
|
<p className="ml-2 text-4xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "阿年的备忘录"}</p>
|
||||||
</div>
|
</div>
|
||||||
{!instanceGeneralSetting.disallowPasswordAuth ? (
|
{!instanceGeneralSetting.disallowPasswordAuth ? (
|
||||||
<PasswordSignInForm redirectPath={redirectTarget} />
|
<PasswordSignInForm redirectPath={redirectTarget} />
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const SignUp = () => {
|
|||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||||
<div className="w-full flex flex-row justify-center items-center mb-6">
|
<div className="w-full flex flex-row justify-center items-center mb-6">
|
||||||
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
||||||
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
|
<p className="ml-2 text-4xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "阿年的备忘录"}</p>
|
||||||
</div>
|
</div>
|
||||||
{canUsePasswordSignUp ? (
|
{canUsePasswordSignUp ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function slugify(text: string): string {
|
|||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^\w\s-]/g, "")
|
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||||
.replace(/[\s_]+/g, "-")
|
.replace(/[\s_]+/g, "-")
|
||||||
.replace(/-+/g, "-")
|
.replace(/-+/g, "-")
|
||||||
.replace(/^-|-$/g, "");
|
.replace(/^-|-$/g, "");
|
||||||
@@ -143,6 +143,8 @@ export function extractHeadings(markdown: string): HeadingItem[] {
|
|||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
let slug = slugify(text);
|
let slug = slugify(text);
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
const count = slugCounts.get(slug) || 0;
|
const count = slugCounts.get(slug) || 0;
|
||||||
slugCounts.set(slug, count + 1);
|
slugCounts.set(slug, count + 1);
|
||||||
if (count > 0) slug = `${slug}-${count}`;
|
if (count > 0) slug = `${slug}-${count}`;
|
||||||
|
|||||||