mirror of
https://github.com/usememos/memos.git
synced 2025-02-20 21:30:55 +01:00
refactor: resource thumbnail
This commit is contained in:
parent
9b1adfbbe9
commit
20570fc771
@ -365,11 +365,6 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR
|
|||||||
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{ID: resource.ID}); err != nil {
|
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{ID: resource.ID}); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to delete resource")
|
return nil, status.Errorf(codes.Internal, "failed to delete resource")
|
||||||
}
|
}
|
||||||
|
|
||||||
thumb := thumbnail{resource}
|
|
||||||
if err := thumb.deleteFile(s.Profile.Data); err != nil {
|
|
||||||
slog.Warn("failed to delete resource thumbnail")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete memo comments
|
// Delete memo comments
|
||||||
|
@ -11,8 +11,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
"github.com/lithammer/shortuuid/v4"
|
"github.com/lithammer/shortuuid/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||||
@ -34,11 +36,15 @@ const (
|
|||||||
// This is unrelated to maximum upload size limit, which is now set through system setting.
|
// This is unrelated to maximum upload size limit, which is now set through system setting.
|
||||||
MaxUploadBufferSizeBytes = 32 << 20
|
MaxUploadBufferSizeBytes = 32 << 20
|
||||||
MebiByte = 1024 * 1024
|
MebiByte = 1024 * 1024
|
||||||
|
// ThumbnailCacheFolder is the folder name where the thumbnail images are stored.
|
||||||
// thumbnailImagePath is the directory to store image thumbnails.
|
ThumbnailCacheFolder = ".thumbnail_cache"
|
||||||
thumbnailImagePath = ".thumbnail_cache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var SupportedThumbnailMimeTypes = []string{
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) CreateResource(ctx context.Context, request *v1pb.CreateResourceRequest) (*v1pb.Resource, error) {
|
func (s *APIV1Service) CreateResource(ctx context.Context, request *v1pb.CreateResourceRequest) (*v1pb.Resource, error) {
|
||||||
user, err := s.GetCurrentUser(ctx)
|
user, err := s.GetCurrentUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -175,74 +181,23 @@ func (s *APIV1Service) GetResourceBinary(ctx context.Context, request *v1pb.GetR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
thumb := thumbnail{resource}
|
if request.Thumbnail && util.HasPrefixes(resource.Type, SupportedThumbnailMimeTypes...) {
|
||||||
returnThumbnail := false
|
thumbnailBlob, err := s.getOrGenerateThumbnail(ctx, resource)
|
||||||
|
|
||||||
if request.Thumbnail && util.HasPrefixes(resource.Type, thumb.supportedMimeTypes()...) {
|
|
||||||
returnThumbnail = true
|
|
||||||
|
|
||||||
thumbnailBlob, err := thumb.getFile(s.Profile.Data)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// thumbnail failures are logged as warnings and not cosidered critical failures as
|
// thumbnail failures are logged as warnings and not cosidered critical failures as
|
||||||
// a resource image can be used in its place
|
// a resource image can be used in its place.
|
||||||
slog.Warn("failed to get resource thumbnail image", slog.Any("error", err))
|
slog.Warn("failed to get resource thumbnail image", slog.Any("error", err))
|
||||||
} else {
|
} else {
|
||||||
httpBody := &httpbody.HttpBody{
|
return &httpbody.HttpBody{
|
||||||
ContentType: resource.Type,
|
ContentType: resource.Type,
|
||||||
Data: thumbnailBlob,
|
Data: thumbnailBlob,
|
||||||
}
|
}, nil
|
||||||
|
|
||||||
return httpBody, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blob := resource.Blob
|
blob, err := s.GetResourceBlob(ctx, resource)
|
||||||
if resource.StorageType == storepb.ResourceStorageType_LOCAL {
|
if err != nil {
|
||||||
resourcePath := filepath.FromSlash(resource.Reference)
|
return nil, status.Errorf(codes.Internal, "failed to get resource blob: %v", err)
|
||||||
if !filepath.IsAbs(resourcePath) {
|
|
||||||
resourcePath = filepath.Join(s.Profile.Data, resourcePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(resourcePath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, status.Errorf(codes.NotFound, "file not found for resource: %s", request.Name)
|
|
||||||
}
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to open the file: %v", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
blob, err = io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to read the file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if returnThumbnail {
|
|
||||||
// wrapping generation logic in a func to exit failed non critical flow using return
|
|
||||||
generateThumbnailBlob := func() ([]byte, error) {
|
|
||||||
thumbnailImage, err := thumb.generateImage(blob)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to generate resource thumbnail")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := thumb.saveAsFile(s.Profile.Data, thumbnailImage); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to save generated resource thumbnail")
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnailBlob, err := thumb.imageToBlob(thumbnailImage)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to convert generate resource thumbnail to bytes")
|
|
||||||
}
|
|
||||||
|
|
||||||
return thumbnailBlob, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnailBlob, err := generateThumbnailBlob()
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("failed to generate a thumbnail blob for the resource", slog.Any("error", err))
|
|
||||||
} else {
|
|
||||||
blob = thumbnailBlob
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType := resource.Type
|
contentType := resource.Type
|
||||||
@ -250,11 +205,10 @@ func (s *APIV1Service) GetResourceBinary(ctx context.Context, request *v1pb.GetR
|
|||||||
contentType += "; charset=utf-8"
|
contentType += "; charset=utf-8"
|
||||||
}
|
}
|
||||||
|
|
||||||
httpBody := &httpbody.HttpBody{
|
return &httpbody.HttpBody{
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Data: blob,
|
Data: blob,
|
||||||
}
|
}, nil
|
||||||
return httpBody, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) UpdateResource(ctx context.Context, request *v1pb.UpdateResourceRequest) (*v1pb.Resource, error) {
|
func (s *APIV1Service) UpdateResource(ctx context.Context, request *v1pb.UpdateResourceRequest) (*v1pb.Resource, error) {
|
||||||
@ -319,12 +273,6 @@ func (s *APIV1Service) DeleteResource(ctx context.Context, request *v1pb.DeleteR
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to delete resource: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to delete resource: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
thumb := thumbnail{resource}
|
|
||||||
if err := thumb.deleteFile(s.Profile.Data); err != nil {
|
|
||||||
slog.Warn("failed to delete resource thumbnail")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &emptypb.Empty{}, nil
|
return &emptypb.Empty{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -436,6 +384,78 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) GetResourceBlob(ctx context.Context, resource *store.Resource) ([]byte, error) {
|
||||||
|
blob := resource.Blob
|
||||||
|
if resource.StorageType == storepb.ResourceStorageType_LOCAL {
|
||||||
|
resourcePath := filepath.FromSlash(resource.Reference)
|
||||||
|
if !filepath.IsAbs(resourcePath) {
|
||||||
|
resourcePath = filepath.Join(s.Profile.Data, resourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(resourcePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, errors.Wrap(err, "file not found")
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "failed to open the file")
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
blob, err = io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to read the file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blob, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOrGenerateThumbnail returns the thumbnail image of the resource.
|
||||||
|
func (s *APIV1Service) getOrGenerateThumbnail(ctx context.Context, resource *store.Resource) ([]byte, error) {
|
||||||
|
thumbnailCacheFolder := filepath.Join(s.Profile.Data, ThumbnailCacheFolder)
|
||||||
|
if err := os.MkdirAll(thumbnailCacheFolder, os.ModePerm); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to create thumbnail cache folder")
|
||||||
|
}
|
||||||
|
filePath := filepath.Join(thumbnailCacheFolder, fmt.Sprintf("%d%s", resource.ID, filepath.Ext(resource.Filename)))
|
||||||
|
if _, err := os.Stat(filePath); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableGeneratorAmount int32 = 32
|
||||||
|
if atomic.LoadInt32(&availableGeneratorAmount) <= 0 {
|
||||||
|
return nil, errors.New("not enough available generator amount")
|
||||||
|
}
|
||||||
|
atomic.AddInt32(&availableGeneratorAmount, -1)
|
||||||
|
defer func() {
|
||||||
|
atomic.AddInt32(&availableGeneratorAmount, 1)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Otherwise, generate and save the thumbnail image.
|
||||||
|
blob, err := s.GetResourceBlob(ctx, resource)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to get resource blob")
|
||||||
|
}
|
||||||
|
image, err := imaging.Decode(bytes.NewReader(blob), imaging.AutoOrientation(true))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to decode thumbnail image")
|
||||||
|
}
|
||||||
|
thumbnailImage := imaging.Resize(image, 512, 0, imaging.Lanczos)
|
||||||
|
if err := imaging.Save(thumbnailImage, filePath); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to save thumbnail file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dstFile, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to open thumbnail file")
|
||||||
|
}
|
||||||
|
defer dstFile.Close()
|
||||||
|
dstBlob, err := io.ReadAll(dstFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to read thumbnail file")
|
||||||
|
}
|
||||||
|
return dstBlob, nil
|
||||||
|
}
|
||||||
|
|
||||||
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
|
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
|
||||||
|
|
||||||
func replaceFilenameWithPathTemplate(path, filename string) string {
|
func replaceFilenameWithPathTemplate(path, filename string) string {
|
||||||
|
@ -1,146 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync/atomic"
|
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
|
|
||||||
"github.com/usememos/memos/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// thumbnail provides functionality to manage thumbnail images
|
|
||||||
// for resources.
|
|
||||||
type thumbnail struct {
|
|
||||||
// The resource the thumbnail is for
|
|
||||||
resource *store.Resource
|
|
||||||
}
|
|
||||||
|
|
||||||
func (thumbnail) supportedMimeTypes() []string {
|
|
||||||
return []string{
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *thumbnail) getFilePath(assetsFolderPath string) (string, error) {
|
|
||||||
if assetsFolderPath == "" {
|
|
||||||
return "", errors.New("aapplication path is not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := filepath.Ext(t.resource.Filename)
|
|
||||||
path := filepath.Join(assetsFolderPath, thumbnailImagePath, fmt.Sprintf("%d%s", t.resource.ID, ext))
|
|
||||||
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *thumbnail) getFile(assetsFolderPath string) ([]byte, error) {
|
|
||||||
path, err := t.getFilePath(assetsFolderPath)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to get thumbnail file path")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dstFile, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to open thumbnail file")
|
|
||||||
}
|
|
||||||
defer dstFile.Close()
|
|
||||||
|
|
||||||
dstBlob, err := io.ReadAll(dstFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to read thumbnail file")
|
|
||||||
}
|
|
||||||
|
|
||||||
return dstBlob, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (thumbnail) generateImage(sourceBlob []byte) (image.Image, error) {
|
|
||||||
var availableGeneratorAmount int32 = 32
|
|
||||||
|
|
||||||
if atomic.LoadInt32(&availableGeneratorAmount) <= 0 {
|
|
||||||
return nil, errors.New("not enough available generator amount")
|
|
||||||
}
|
|
||||||
|
|
||||||
atomic.AddInt32(&availableGeneratorAmount, -1)
|
|
||||||
defer func() {
|
|
||||||
atomic.AddInt32(&availableGeneratorAmount, 1)
|
|
||||||
}()
|
|
||||||
|
|
||||||
reader := bytes.NewReader(sourceBlob)
|
|
||||||
src, err := imaging.Decode(reader, imaging.AutoOrientation(true))
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to decode thumbnail image")
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnailImage := imaging.Resize(src, 512, 0, imaging.Lanczos)
|
|
||||||
return thumbnailImage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *thumbnail) saveAsFile(assetsFolderPath string, thumbnailImage image.Image) error {
|
|
||||||
path, err := t.getFilePath(assetsFolderPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "failed to get thumbnail file path")
|
|
||||||
}
|
|
||||||
|
|
||||||
dstDir := filepath.Dir(path)
|
|
||||||
if err := os.MkdirAll(dstDir, os.ModePerm); err != nil {
|
|
||||||
return errors.Wrap(err, "failed to create thumbnail directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := imaging.Save(thumbnailImage, path); err != nil {
|
|
||||||
return errors.Wrap(err, "failed to save thumbnail file")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *thumbnail) imageToBlob(thumbnailImage image.Image) ([]byte, error) {
|
|
||||||
mimeTypeMap := map[string]imaging.Format{
|
|
||||||
"image/png": imaging.JPEG,
|
|
||||||
"image/jpeg": imaging.PNG,
|
|
||||||
}
|
|
||||||
|
|
||||||
imgFormat, ok := mimeTypeMap[t.resource.Type]
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("failed to map resource type to an image encoder format")
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
if err := imaging.Encode(buf, thumbnailImage, imgFormat); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to convert thumbnail image to bytes")
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *thumbnail) deleteFile(assetsFolderPath string) error {
|
|
||||||
path, err := t.getFilePath(assetsFolderPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "failed to get thumbnail file path")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return errors.Wrap(err, "failed to check thumbnail image stat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Remove(path); err != nil {
|
|
||||||
return errors.Wrap(err, "failed to delete thumbnail file")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -17,7 +17,6 @@ func (s *APIV1Service) GetWorkspaceProfile(ctx context.Context, _ *v1pb.GetWorks
|
|||||||
Mode: s.Profile.Mode,
|
Mode: s.Profile.Mode,
|
||||||
InstanceUrl: s.Profile.InstanceURL,
|
InstanceUrl: s.Profile.InstanceURL,
|
||||||
}
|
}
|
||||||
println("workspaceProfile: ", workspaceProfile.Mode)
|
|
||||||
owner, err := s.GetInstanceOwner(ctx)
|
owner, err := s.GetInstanceOwner(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get instance owner: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get instance owner: %v", err)
|
||||||
|
@ -146,7 +146,12 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo
|
|||||||
fields = append(fields, "`memo`.`content` AS `content`")
|
fields = append(fields, "`memo`.`content` AS `content`")
|
||||||
}
|
}
|
||||||
|
|
||||||
query := "SELECT " + strings.Join(fields, ", ") + " FROM `memo` LEFT JOIN `memo_organizer` ON `memo`.`id` = `memo_organizer`.`memo_id` AND `memo`.`creator_id` = `memo_organizer`.`user_id` LEFT JOIN `memo_relation` ON `memo`.`id` = `memo_relation`.`memo_id` AND `memo_relation`.`type` = \"COMMENT\" WHERE " + strings.Join(where, " AND ") + " HAVING " + strings.Join(having, " AND ") + " ORDER BY " + strings.Join(orders, ", ")
|
query := "SELECT " + strings.Join(fields, ", ") + " FROM `memo`" + " " +
|
||||||
|
"LEFT JOIN `memo_organizer` ON `memo`.`id` = `memo_organizer`.`memo_id` AND `memo`.`creator_id` = `memo_organizer`.`user_id`" + " " +
|
||||||
|
"LEFT JOIN `memo_relation` ON `memo`.`id` = `memo_relation`.`memo_id` AND `memo_relation`.`type` = 'COMMENT'" + " " +
|
||||||
|
"WHERE " + strings.Join(where, " AND ") + " " +
|
||||||
|
"HAVING " + strings.Join(having, " AND ") + " " +
|
||||||
|
"ORDER BY " + strings.Join(orders, ", ")
|
||||||
if find.Limit != nil {
|
if find.Limit != nil {
|
||||||
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
|
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
|
||||||
if find.Offset != nil {
|
if find.Offset != nil {
|
||||||
|
@ -29,14 +29,14 @@ const MemoResourceListView = ({ resources = [] }: { resources: Resource[] }) =>
|
|||||||
|
|
||||||
const MediaCard = ({ resource }: { resource: Resource }) => {
|
const MediaCard = ({ resource }: { resource: Resource }) => {
|
||||||
const type = getResourceType(resource);
|
const type = getResourceType(resource);
|
||||||
const url = getResourceUrl(resource);
|
const resourceUrl = getResourceUrl(resource);
|
||||||
|
|
||||||
if (type === "image/*") {
|
if (type === "image/*") {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className="cursor-pointer min-h-full w-auto object-cover"
|
className="cursor-pointer min-h-full w-auto object-cover"
|
||||||
src={url + "?thumbnail=true"}
|
src={resource.externalLink ? resourceUrl : resourceUrl + "?thumbnail=true"}
|
||||||
onClick={() => handleImageClick(url)}
|
onClick={() => handleImageClick(resourceUrl)}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
@ -47,7 +47,7 @@ const MemoResourceListView = ({ resources = [] }: { resources: Resource[] }) =>
|
|||||||
className="cursor-pointer w-full h-full object-contain bg-zinc-100 dark:bg-zinc-800"
|
className="cursor-pointer w-full h-full object-contain bg-zinc-100 dark:bg-zinc-800"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
src={url}
|
src={resourceUrl}
|
||||||
controls
|
controls
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user