Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
a0d83e1a
Commit
a0d83e1a
authored
Apr 01, 2026
by
memoclaw
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(web): refine attachment media layout
parent
cdbe40a3
Changes
2
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
118 additions
and
42 deletions
+118
-42
AttachmentCard.tsx
...src/components/MemoMetadata/Attachment/AttachmentCard.tsx
+9
-2
AttachmentListView.tsx
...components/MemoMetadata/Attachment/AttachmentListView.tsx
+109
-40
No files found.
web/src/components/MemoMetadata/Attachment/AttachmentCard.tsx
View file @
a0d83e1a
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
{
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
{
getAttachmentT
humbnailUrl
,
getAttachmentT
ype
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
interface
AttachmentCardProps
{
interface
AttachmentCardProps
{
attachment
:
Attachment
;
attachment
:
Attachment
;
...
@@ -15,10 +15,17 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps)
...
@@ -15,10 +15,17 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps)
if
(
attachmentType
===
"image/*"
)
{
if
(
attachmentType
===
"image/*"
)
{
return
(
return
(
<
img
<
img
src=
{
sourceUrl
}
src=
{
getAttachmentThumbnailUrl
(
attachment
)
}
alt=
{
attachment
.
filename
}
alt=
{
attachment
.
filename
}
className=
{
cn
(
"w-full h-full object-cover rounded-lg cursor-pointer"
,
className
)
}
className=
{
cn
(
"w-full h-full object-cover rounded-lg cursor-pointer"
,
className
)
}
onClick=
{
onClick
}
onClick=
{
onClick
}
onError=
{
(
e
)
=>
{
const
target
=
e
.
currentTarget
;
if
(
target
.
src
.
includes
(
"?thumbnail=true"
))
{
target
.
src
=
sourceUrl
;
}
}
}
decoding=
"async"
loading=
"lazy"
loading=
"lazy"
/>
/>
);
);
...
...
web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx
View file @
a0d83e1a
import
{
FileIcon
,
Paperclip
Icon
}
from
"lucide-react"
;
import
{
DownloadIcon
,
FileIcon
,
Maximize2Icon
,
PaperclipIcon
,
Play
Icon
}
from
"lucide-react"
;
import
{
useMemo
}
from
"react"
;
import
{
useMemo
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
...
@@ -6,75 +6,144 @@ import { getAttachmentUrl } from "@/utils/attachment";
...
@@ -6,75 +6,144 @@ import { getAttachmentUrl } from "@/utils/attachment";
import
SectionHeader
from
"../SectionHeader"
;
import
SectionHeader
from
"../SectionHeader"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
AudioAttachmentItem
from
"./AudioAttachmentItem"
;
import
AudioAttachmentItem
from
"./AudioAttachmentItem"
;
import
{
getAttachmentMetadata
,
isImageAttachment
,
separateAttachments
}
from
"./attachmentViewHelpers"
;
import
{
getAttachmentMetadata
,
isImageAttachment
,
isVideoAttachment
,
separateAttachments
}
from
"./attachmentViewHelpers"
;
interface
AttachmentListViewProps
{
interface
AttachmentListViewProps
{
attachments
:
Attachment
[];
attachments
:
Attachment
[];
onImagePreview
?:
(
urls
:
string
[],
index
:
number
)
=>
void
;
onImagePreview
?:
(
urls
:
string
[],
index
:
number
)
=>
void
;
}
}
const
DocumentItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
AttachmentMeta
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
{
fileTypeLabel
,
fileSizeLabel
}
=
getAttachmentMetadata
(
attachment
);
const
{
fileTypeLabel
,
fileSizeLabel
}
=
getAttachmentMetadata
(
attachment
);
return
(
return
(
<
div
className=
"flex items-center gap-1 px-1 py-1 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-accent/20 transition-colors whitespace-nowrap"
>
<
div
className=
"mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground"
>
<
div
className=
"shrink-0 w-5 h-5 rounded overflow-hidden bg-muted/40 flex items-center justify-center"
>
<
FileIcon
className=
"w-3 h-3 text-muted-foreground"
/>
</
div
>
<
div
className=
"flex items-center gap-1 min-w-0"
>
<
span
className=
"text-xs truncate"
title=
{
attachment
.
filename
}
>
{
attachment
.
filename
}
</
span
>
<
div
className=
"flex items-center gap-1 text-xs text-muted-foreground shrink-0"
>
<
span
className=
"text-muted-foreground/50"
>
•
</
span
>
<
span
>
{
fileTypeLabel
}
</
span
>
<
span
>
{
fileTypeLabel
}
</
span
>
{
fileSizeLabel
&&
(
{
fileSizeLabel
&&
(
<>
<>
<
span
className=
"text-muted-foreground/5
0"
>
•
</
span
>
<
span
className=
"text-muted-foreground/4
0"
>
•
</
span
>
<
span
>
{
fileSizeLabel
}
</
span
>
<
span
>
{
fileSizeLabel
}
</
span
>
</>
</>
)
}
)
}
</
div
>
</
div
>
);
};
const
DocumentItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
return
(
<
div
className=
"group flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-background/65 px-3 py-2.5 transition-colors hover:bg-accent/20"
>
<
div
className=
"flex min-w-0 items-center gap-3"
>
<
div
className=
"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-muted/50 text-muted-foreground"
>
<
FileIcon
className=
"h-4 w-4"
/>
</
div
>
<
div
className=
"min-w-0"
>
<
div
className=
"truncate text-sm font-medium leading-tight text-foreground"
title=
{
attachment
.
filename
}
>
{
attachment
.
filename
}
</
div
>
<
AttachmentMeta
attachment=
{
attachment
}
/>
</
div
>
</
div
>
</
div
>
<
DownloadIcon
className=
"h-4 w-4 shrink-0 text-muted-foreground/60 transition-colors group-hover:text-foreground/70"
/>
</
div
>
</
div
>
);
);
};
};
interface
VisualItemProps
{
interface
VisualItemProps
{
attachment
:
Attachment
;
attachment
:
Attachment
;
onImageClick
?:
(
url
:
string
)
=>
void
;
featured
?:
boolean
;
}
}
const
VisualItem
=
({
attachment
,
onImageClick
}:
VisualItemProps
)
=>
{
const
ImageItem
=
({
attachment
,
onImageClick
,
featured
=
false
}:
VisualItemProps
&
{
onImageClick
?:
(
url
:
string
)
=>
void
})
=>
{
const
isInteractive
=
isImageAttachment
(
attachment
)
&&
Boolean
(
onImageClick
);
const
handleClick
=
()
=>
{
const
handleClick
=
()
=>
{
if
(
isInteractive
)
{
onImageClick
?.(
getAttachmentUrl
(
attachment
));
onImageClick
?.(
getAttachmentUrl
(
attachment
));
}
};
};
return
(
return
(
<
button
type=
"button"
className=
{
cn
(
"group block w-full text-left"
,
featured
?
"max-w-[18rem] sm:max-w-[20rem]"
:
""
)
}
onClick=
{
handleClick
}
>
<
div
<
div
className=
{
cn
(
className=
{
cn
(
"aspect-square rounded-lg overflow-hidden bg-muted/40 border border-border hover:border-accent/50 transition-all group
"
,
"relative overflow-hidden rounded-xl border border-border/70 bg-muted/30 transition-colors hover:border-accent/40
"
,
isInteractive
&&
"cursor-pointer
"
,
featured
?
"aspect-[4/3]"
:
"aspect-square
"
,
)
}
)
}
onClick=
{
handleClick
}
>
>
<
AttachmentCard
attachment=
{
attachment
}
className=
"rounded-none"
/>
<
AttachmentCard
attachment=
{
attachment
}
className=
"h-full w-full rounded-none transition-transform duration-300 group-hover:scale-[1.02]"
/>
<
div
className=
"pointer-events-none absolute inset-0 bg-gradient-to-t from-black/15 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
/>
<
span
className=
"pointer-events-none absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm"
>
<
Maximize2Icon
className=
"h-3.5 w-3.5"
/>
</
span
>
</
div
>
</
button
>
);
};
const
ImageGallery
=
({
attachments
,
onImageClick
}:
{
attachments
:
Attachment
[];
onImageClick
?:
(
url
:
string
)
=>
void
})
=>
{
if
(
attachments
.
length
===
1
)
{
return
(
<
div
className=
"flex"
>
<
ImageItem
attachment=
{
attachments
[
0
]
}
featured
onImageClick=
{
onImageClick
}
/>
</
div
>
);
}
return
(
<
div
className=
"grid max-w-[22rem] grid-cols-2 gap-1.5 sm:max-w-[24rem]"
>
{
attachments
.
map
((
attachment
)
=>
(
<
ImageItem
key=
{
attachment
.
name
}
attachment=
{
attachment
}
onImageClick=
{
onImageClick
}
/>
))
}
</
div
>
</
div
>
);
);
};
};
const
VisualGrid
=
({
attachments
,
onImageClick
}:
{
attachments
:
Attachment
[];
onImageClick
?:
(
url
:
string
)
=>
void
})
=>
(
const
VideoItem
=
({
attachment
}:
VisualItemProps
)
=>
(
<
div
className=
"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2"
>
<
div
className=
"w-full max-w-[20rem] overflow-hidden rounded-xl border border-border/70 bg-background/80"
>
<
div
className=
"relative aspect-video bg-muted/40"
>
<
AttachmentCard
attachment=
{
attachment
}
className=
"h-full w-full rounded-none"
/>
<
span
className=
"pointer-events-none absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm"
>
<
PlayIcon
className=
"h-3.5 w-3.5 fill-current"
/>
</
span
>
</
div
>
<
div
className=
"border-t border-border/60 px-3 py-2.5"
>
<
div
className=
"truncate text-sm font-medium leading-tight text-foreground"
title=
{
attachment
.
filename
}
>
{
attachment
.
filename
}
</
div
>
<
AttachmentMeta
attachment=
{
attachment
}
/>
</
div
>
</
div
>
);
const
VideoList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
<
div
className=
"flex flex-wrap gap-2"
>
{
attachments
.
map
((
attachment
)
=>
(
{
attachments
.
map
((
attachment
)
=>
(
<
Vi
sualItem
key=
{
attachment
.
name
}
attachment=
{
attachment
}
onImageClick=
{
onImageClick
}
/>
<
Vi
deoItem
key=
{
attachment
.
name
}
attachment=
{
attachment
}
/>
))
}
))
}
</
div
>
</
div
>
);
);
const
VisualSection
=
({
attachments
,
onImageClick
}:
{
attachments
:
Attachment
[];
onImageClick
?:
(
url
:
string
)
=>
void
})
=>
{
const
images
=
attachments
.
filter
(
isImageAttachment
);
const
videos
=
attachments
.
filter
(
isVideoAttachment
);
return
(
<
div
className=
"flex flex-col gap-2"
>
{
images
.
length
>
0
&&
<
ImageGallery
attachments=
{
images
}
onImageClick=
{
onImageClick
}
/>
}
{
videos
.
length
>
0
&&
(
<
div
className=
"flex flex-col gap-2"
>
{
images
.
length
>
0
&&
<
Divider
/>
}
<
VideoList
attachments=
{
videos
}
/>
</
div
>
)
}
</
div
>
);
};
const
AudioList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
const
AudioList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
<
div
className=
"flex flex-col gap-2"
>
<
div
className=
"flex flex-col gap-2"
>
{
attachments
.
map
((
attachment
)
=>
(
{
attachments
.
map
((
attachment
)
=>
(
...
@@ -84,7 +153,7 @@ const AudioList = ({ attachments }: { attachments: Attachment[] }) => (
...
@@ -84,7 +153,7 @@ const AudioList = ({ attachments }: { attachments: Attachment[] }) => (
);
);
const
DocsList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
const
DocsList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
<
div
className=
"flex flex-col gap-
0.5
"
>
<
div
className=
"flex flex-col gap-
2
"
>
{
attachments
.
map
((
attachment
)
=>
(
{
attachments
.
map
((
attachment
)
=>
(
<
a
key=
{
attachment
.
name
}
href=
{
getAttachmentUrl
(
attachment
)
}
download
title=
{
`Download ${attachment.filename}`
}
>
<
a
key=
{
attachment
.
name
}
href=
{
getAttachmentUrl
(
attachment
)
}
download
title=
{
`Download ${attachment.filename}`
}
>
<
DocumentItem
attachment=
{
attachment
}
/>
<
DocumentItem
attachment=
{
attachment
}
/>
...
@@ -93,7 +162,7 @@ const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
...
@@ -93,7 +162,7 @@ const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
</
div
>
</
div
>
);
);
const
Divider
=
()
=>
<
div
className=
"border-t
mt-1 border-border opacity-6
0"
/>;
const
Divider
=
()
=>
<
div
className=
"border-t
border-border/70 opacity-8
0"
/>;
const
AttachmentListView
=
({
attachments
,
onImagePreview
}:
AttachmentListViewProps
)
=>
{
const
AttachmentListView
=
({
attachments
,
onImagePreview
}:
AttachmentListViewProps
)
=>
{
const
{
visual
,
audio
,
docs
}
=
useMemo
(()
=>
separateAttachments
(
attachments
),
[
attachments
]);
const
{
visual
,
audio
,
docs
}
=
useMemo
(()
=>
separateAttachments
(
attachments
),
[
attachments
]);
...
@@ -117,8 +186,8 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
...
@@ -117,8 +186,8 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
SectionHeader
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
attachments
.
length
}
/>
<
SectionHeader
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
attachments
.
length
}
/>
<
div
className=
"
p-1.5 flex flex-col gap-1
"
>
<
div
className=
"
flex flex-col gap-2 p-2
"
>
{
visual
.
length
>
0
&&
<
Visual
Grid
attachments=
{
visual
}
onImageClick=
{
handleImageClick
}
/>
}
{
visual
.
length
>
0
&&
<
Visual
Section
attachments=
{
visual
}
onImageClick=
{
handleImageClick
}
/>
}
{
visual
.
length
>
0
&&
sectionCount
>
1
&&
<
Divider
/>
}
{
visual
.
length
>
0
&&
sectionCount
>
1
&&
<
Divider
/>
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment