mirror of https://github.com/usememos/memos.git
Merge branch 'usememos:main' into fix-calendar-filter-ui
This commit is contained in:
commit
c807ec38a6
|
|
@ -100,7 +100,7 @@ Don't want to install yet? Try our [live demo](https://demo.usememos.com/) first
|
|||
- **Kubernetes** - Helm charts and manifests available
|
||||
- **Build from Source** - For development and customization
|
||||
|
||||
See our [installation guide](https://usememos.com/docs/installation) for detailed instructions.
|
||||
See our [installation guide](https://usememos.com/docs/deploy) for detailed instructions.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -131,6 +132,7 @@ func init() {
|
|||
}
|
||||
|
||||
viper.SetEnvPrefix("memos")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||
viper.AutomaticEnv()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ func (s *APIV1Service) ListMemoRelations(ctx context.Context, request *v1pb.List
|
|||
MemoFilter: &memoFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, status.Errorf(codes.Internal, "failed to list memo relations: %v", err)
|
||||
}
|
||||
for _, raw := range tempList {
|
||||
relation, err := s.convertMemoRelationFromStore(ctx, raw)
|
||||
|
|
@ -114,7 +114,7 @@ func (s *APIV1Service) ListMemoRelations(ctx context.Context, request *v1pb.List
|
|||
MemoFilter: &memoFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, status.Errorf(codes.Internal, "failed to list related memo relations: %v", err)
|
||||
}
|
||||
for _, raw := range tempList {
|
||||
relation, err := s.convertMemoRelationFromStore(ctx, raw)
|
||||
|
|
@ -133,7 +133,7 @@ func (s *APIV1Service) ListMemoRelations(ctx context.Context, request *v1pb.List
|
|||
func (s *APIV1Service) convertMemoRelationFromStore(ctx context.Context, memoRelation *store.MemoRelation) (*v1pb.MemoRelation, error) {
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoRelation.MemoID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo: %v", err)
|
||||
}
|
||||
memoSnippet, err := s.getMemoContentSnippet(memo.Content)
|
||||
if err != nil {
|
||||
|
|
@ -141,7 +141,7 @@ func (s *APIV1Service) convertMemoRelationFromStore(ctx context.Context, memoRel
|
|||
}
|
||||
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoRelation.RelatedMemoID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, status.Errorf(codes.Internal, "failed to get related memo: %v", err)
|
||||
}
|
||||
relatedMemoSnippet, err := s.getMemoContentSnippet(relatedMemo.Content)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR
|
|||
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo: %v", err)
|
||||
}
|
||||
if memo == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ func (s *APIV1Service) ListShortcuts(ctx context.Context, request *v1pb.ListShor
|
|||
Key: storepb.UserSetting_SHORTCUTS,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
||||
}
|
||||
if userSetting == nil {
|
||||
return &v1pb.ListShortcutsResponse{
|
||||
|
|
@ -186,7 +186,7 @@ func (s *APIV1Service) CreateShortcut(ctx context.Context, request *v1pb.CreateS
|
|||
|
||||
_, err = s.Store.UpsertUserSetting(ctx, userSetting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
||||
}
|
||||
|
||||
return &v1pb.Shortcut{
|
||||
|
|
@ -313,7 +313,7 @@ func (s *APIV1Service) DeleteShortcut(ctx context.Context, request *v1pb.DeleteS
|
|||
}
|
||||
_, err = s.Store.UpsertUserSetting(ctx, userSetting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -13,7 +12,6 @@ import (
|
|||
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/ast"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"google.golang.org/grpc/codes"
|
||||
|
|
@ -163,7 +161,7 @@ func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserR
|
|||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
||||
return nil, status.Errorf(codes.Internal, "failed to generate password hash: %v", err)
|
||||
}
|
||||
|
||||
user, err := s.Store.CreateUser(ctx, &store.User{
|
||||
|
|
@ -272,7 +270,7 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
|
|||
case "password":
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
||||
return nil, status.Errorf(codes.Internal, "failed to generate password hash: %v", err)
|
||||
}
|
||||
passwordHashStr := string(passwordHash)
|
||||
update.PasswordHash = &passwordHashStr
|
||||
|
|
|
|||
|
|
@ -126,6 +126,21 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
|
|||
onChange={onShortcutFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2">{t("common.learn-more")}:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<a
|
||||
className="text-primary hover:underline"
|
||||
href="https://www.usememos.com/docs/usage/shortcuts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Docs - Shortcuts
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FileIcon, PaperclipIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
|
||||
|
|
@ -11,13 +11,18 @@ interface AttachmentListProps {
|
|||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
// Type guards for attachment types
|
||||
const isImageAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "image/*";
|
||||
const isVideoAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "video/*";
|
||||
const isMediaAttachment = (attachment: Attachment): boolean => isImageAttachment(attachment) || isVideoAttachment(attachment);
|
||||
|
||||
// Separate attachments into media (images/videos) and documents
|
||||
const separateMediaAndDocs = (attachments: Attachment[]): { media: Attachment[]; docs: Attachment[] } => {
|
||||
const media: Attachment[] = [];
|
||||
const docs: Attachment[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const attachmentType = getAttachmentType(attachment);
|
||||
if (attachmentType === "image/*" || attachmentType === "video/*") {
|
||||
if (isMediaAttachment(attachment)) {
|
||||
media.push(attachment);
|
||||
} else {
|
||||
docs.push(attachment);
|
||||
|
|
@ -55,27 +60,39 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const MediaGrid = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick: (url: string) => void }) => (
|
||||
interface MediaItemProps {
|
||||
attachment: Attachment;
|
||||
onImageClick: (url: string) => void;
|
||||
}
|
||||
|
||||
const MediaItem = ({ attachment, onImageClick }: MediaItemProps) => {
|
||||
const isImage = isImageAttachment(attachment);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isImage) {
|
||||
onImageClick(getAttachmentUrl(attachment));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="aspect-square rounded-lg overflow-hidden bg-muted/40 border border-border hover:border-accent/50 transition-all cursor-pointer group"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<AttachmentCard attachment={attachment} className="rounded-none" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MediaGridProps {
|
||||
attachments: Attachment[];
|
||||
onImageClick: (url: string) => void;
|
||||
}
|
||||
|
||||
const MediaGrid = ({ attachments, onImageClick }: MediaGridProps) => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.name}
|
||||
className="aspect-square rounded-lg overflow-hidden bg-muted/40 border border-border hover:border-accent/50 transition-all cursor-pointer group"
|
||||
onClick={() => onImageClick(getAttachmentUrl(attachment))}
|
||||
>
|
||||
<div className="w-full h-full relative">
|
||||
<AttachmentCard attachment={attachment} className="rounded-none" />
|
||||
{getAttachmentType(attachment) === "video/*" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/40 transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-white/80 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-black fill-current ml-0.5" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MediaItem key={attachment.name} attachment={attachment} onImageClick={onImageClick} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -98,18 +115,20 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
|
|||
mimeType: undefined,
|
||||
});
|
||||
|
||||
const { media: mediaItems, docs: docItems } = separateMediaAndDocs(attachments);
|
||||
const { media: mediaItems, docs: docItems } = useMemo(() => separateMediaAndDocs(attachments), [attachments]);
|
||||
|
||||
// Pre-compute image URLs for preview dialog to avoid filtering on every click
|
||||
const imageAttachments = useMemo(() => mediaItems.filter(isImageAttachment), [mediaItems]);
|
||||
const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]);
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleImageClick = (imgUrl: string) => {
|
||||
const imageAttachments = mediaItems.filter((a) => getAttachmentType(a) === "image/*");
|
||||
const imgUrls = imageAttachments.map((a) => getAttachmentUrl(a));
|
||||
const index = imgUrls.findIndex((url) => url === imgUrl);
|
||||
const index = imageUrls.findIndex((url) => url === imgUrl);
|
||||
const mimeType = imageAttachments[index]?.type;
|
||||
setPreviewImage({ open: true, urls: imgUrls, index, mimeType });
|
||||
setPreviewImage({ open: true, urls: imageUrls, index, mimeType });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Reference in New Issue