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
77e9376e
Commit
77e9376e
authored
Jan 03, 2026
by
Johnny
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore: improve metadata section UI consistency and maintainability
parent
5612fb8f
Changes
6
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
193 additions
and
119 deletions
+193
-119
AttachmentList.tsx
...omponents/MemoView/components/metadata/AttachmentList.tsx
+85
-32
MetadataCard.tsx
.../components/MemoView/components/metadata/MetadataCard.tsx
+0
-22
RelationCard.tsx
.../components/MemoView/components/metadata/RelationCard.tsx
+4
-2
RelationList.tsx
.../components/MemoView/components/metadata/RelationList.tsx
+55
-62
SectionHeader.tsx
...components/MemoView/components/metadata/SectionHeader.tsx
+48
-0
index.ts
web/src/components/MemoView/components/metadata/index.ts
+1
-1
No files found.
web/src/components/MemoView/components/metadata/AttachmentList.tsx
View file @
77e9376e
import
{
FileIcon
,
PaperclipIcon
}
from
"lucide-react"
;
import
{
useState
}
from
"react"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
{
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
MemoAttachment
from
"../../../MemoAttachmen
t"
;
import
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/forma
t"
;
import
PreviewImageDialog
from
"../../../PreviewImageDialog"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
SectionHeader
from
"./SectionHeader"
;
interface
AttachmentListProps
{
attachments
:
Attachment
[];
}
function
separateMediaAndDocs
(
attachments
:
Attachment
[]):
{
media
:
Attachment
[];
docs
:
Attachment
[]
}
{
const
separateMediaAndDocs
=
(
attachments
:
Attachment
[]):
{
media
:
Attachment
[];
docs
:
Attachment
[]
}
=>
{
const
media
:
Attachment
[]
=
[];
const
docs
:
Attachment
[]
=
[];
...
...
@@ -23,7 +25,70 @@ function separateMediaAndDocs(attachments: Attachment[]): { media: Attachment[];
}
return
{
media
,
docs
};
}
};
const
DocumentItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
fileTypeLabel
=
getFileTypeLabel
(
attachment
.
type
);
const
fileSizeLabel
=
attachment
.
size
?
formatFileSize
(
Number
(
attachment
.
size
))
:
undefined
;
return
(
<
div
className=
"flex items-center gap-1 px-1.5 py-1 rounded hover:bg-accent/20 transition-colors whitespace-nowrap"
>
<
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 font-medium 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
>
{
fileSizeLabel
&&
(
<>
<
span
className=
"text-muted-foreground/50"
>
•
</
span
>
<
span
>
{
fileSizeLabel
}
</
span
>
</>
)
}
</
div
>
</
div
>
</
div
>
);
};
const
MediaGrid
=
({
attachments
,
onImageClick
}:
{
attachments
:
Attachment
[];
onImageClick
:
(
url
:
string
)
=>
void
})
=>
(
<
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
>
))
}
</
div
>
);
const
DocsList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
<
div
className=
"flex flex-col gap-0.5"
>
{
attachments
.
map
((
attachment
)
=>
(
<
a
key=
{
attachment
.
name
}
href=
{
getAttachmentUrl
(
attachment
)
}
download
title=
{
`Download ${attachment.filename}`
}
>
<
DocumentItem
attachment=
{
attachment
}
/>
</
a
>
))
}
</
div
>
);
const
AttachmentList
=
({
attachments
}:
AttachmentListProps
)
=>
{
const
[
previewImage
,
setPreviewImage
]
=
useState
<
{
open
:
boolean
;
urls
:
string
[];
index
:
number
;
mimeType
?:
string
}
>
({
...
...
@@ -33,45 +98,33 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
mimeType
:
undefined
,
});
const
handleImageClick
=
(
imgUrl
:
string
,
mediaAttachments
:
Attachment
[])
=>
{
const
imageAttachments
=
mediaAttachments
.
filter
((
attachment
)
=>
getAttachmentType
(
attachment
)
===
"image/*"
);
const
imgUrls
=
imageAttachments
.
map
((
attachment
)
=>
getAttachmentUrl
(
attachment
));
const
index
=
imgUrls
.
findIndex
((
url
)
=>
url
===
imgUrl
);
const
mimeType
=
imageAttachments
[
index
]?.
type
;
setPreviewImage
({
open
:
true
,
urls
:
imgUrls
,
index
,
mimeType
});
};
const
{
media
:
mediaItems
,
docs
:
docItems
}
=
separateMediaAndDocs
(
attachments
);
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
mimeType
=
imageAttachments
[
index
]?.
type
;
setPreviewImage
({
open
:
true
,
urls
:
imgUrls
,
index
,
mimeType
});
};
return
(
<>
{
mediaItems
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
{
mediaItems
.
map
((
attachment
)
=>
(
<
div
key=
{
attachment
.
name
}
className=
"max-w-[60%] w-fit flex flex-col justify-start items-start shrink-0"
>
<
AttachmentCard
attachment=
{
attachment
}
onClick=
{
()
=>
{
handleImageClick
(
getAttachmentUrl
(
attachment
),
mediaItems
);
}
}
className=
"max-h-64 grow"
/>
</
div
>
))
}
</
div
>
)
}
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
SectionHeader
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
attachments
.
length
}
/>
{
docItems
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
{
docItems
.
map
((
attachment
)
=>
(
<
MemoAttachment
key=
{
attachment
.
name
}
attachment=
{
attachment
}
/>
))
}
<
div
className=
"p-2 flex flex-col gap-1"
>
{
mediaItems
.
length
>
0
&&
<
MediaGrid
attachments=
{
mediaItems
}
onImageClick=
{
handleImageClick
}
/>
}
{
mediaItems
.
length
>
0
&&
docItems
.
length
>
0
&&
<
div
className=
"border-t border-border opacity-60"
/>
}
{
docItems
.
length
>
0
&&
<
DocsList
attachments=
{
docItems
}
/>
}
</
div
>
</
div
>
)
}
<
PreviewImageDialog
open=
{
previewImage
.
open
}
...
...
web/src/components/MemoView/components/metadata/MetadataCard.tsx
deleted
100644 → 0
View file @
5612fb8f
import
{
ReactNode
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
interface
MetadataCardProps
{
children
:
ReactNode
;
className
?:
string
;
}
const
MetadataCard
=
({
children
,
className
}:
MetadataCardProps
)
=>
{
return
(
<
div
className=
{
cn
(
"relative flex flex-col justify-start items-start w-full px-2 pt-2 pb-1.5 bg-muted/50 rounded-lg border border-border"
,
className
,
)
}
>
{
children
}
</
div
>
);
};
export
default
MetadataCard
;
web/src/components/MemoView/components/metadata/RelationCard.tsx
View file @
77e9376e
...
...
@@ -15,14 +15,16 @@ const RelationCard = ({ memo, parentPage, className }: RelationCardProps) => {
return
(
<
Link
className=
{
cn
(
"
w-full flex flex-row justify-start items-center text-sm leading-5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-1 py-1 transition-colors
"
,
"
flex items-center gap-1 px-1 py-1 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-accent/20 transition-colors group
"
,
className
,
)
}
to=
{
`/${memo.name}`
}
viewTransition
state=
{
{
from
:
parentPage
}
}
>
<
span
className=
"text-[10px] opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1"
>
{
memoId
.
slice
(
0
,
6
)
}
</
span
>
<
span
className=
"text-[8px] font-mono px-1 py-0.5 rounded border border-border bg-muted/40 group-hover:bg-accent/30 transition-colors shrink-0"
>
{
memoId
.
slice
(
0
,
6
)
}
</
span
>
<
span
className=
"truncate"
>
{
memo
.
snippet
}
</
span
>
</
Link
>
);
...
...
web/src/components/MemoView/components/metadata/RelationList.tsx
View file @
77e9376e
import
{
LinkIcon
,
MilestoneIcon
}
from
"lucide-react"
;
import
{
useState
}
from
"react"
;
import
{
use
Memo
,
use
State
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
MemoRelation_Type
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
MetadataCard
from
"./MetadataCard"
;
import
RelationCard
from
"./RelationCard"
;
import
SectionHeader
from
"./SectionHeader"
;
interface
RelationListProps
{
relations
:
MemoRelation
[];
...
...
@@ -16,75 +16,68 @@ interface RelationListProps {
function
RelationList
({
relations
,
currentMemoName
,
parentPage
,
className
}:
RelationListProps
)
{
const
t
=
useTranslate
();
const
[
selectedTab
,
setSelected
Tab
]
=
useState
<
"referencing"
|
"referenced"
>
(
"referencing"
);
const
[
activeTab
,
setActive
Tab
]
=
useState
<
"referencing"
|
"referenced"
>
(
"referencing"
);
const
referencingRelations
=
relations
.
filter
(
(
relation
)
=>
relation
.
type
===
MemoRelation_Type
.
REFERENCE
&&
relation
.
memo
?.
name
===
currentMemoName
&&
relation
.
relatedMemo
?.
name
!==
currentMemoName
,
);
const
referencedRelations
=
relations
.
filter
(
(
relation
)
=>
relation
.
type
===
MemoRelation_Type
.
REFERENCE
&&
relation
.
memo
?.
name
!==
currentMemoName
&&
relation
.
relatedMemo
?.
name
===
currentMemoName
,
);
const
{
referencingRelations
,
referencedRelations
}
=
useMemo
(()
=>
{
return
{
referencingRelations
:
relations
.
filter
(
(
r
)
=>
r
.
type
===
MemoRelation_Type
.
REFERENCE
&&
r
.
memo
?.
name
===
currentMemoName
&&
r
.
relatedMemo
?.
name
!==
currentMemoName
,
),
referencedRelations
:
relations
.
filter
(
(
r
)
=>
r
.
type
===
MemoRelation_Type
.
REFERENCE
&&
r
.
memo
?.
name
!==
currentMemoName
&&
r
.
relatedMemo
?.
name
===
currentMemoName
,
),
};
},
[
relations
,
currentMemoName
]);
if
(
referencingRelations
.
length
===
0
&&
referencedRelations
.
length
===
0
)
{
return
null
;
}
const
activeTab
=
referencingRelations
.
length
===
0
?
"referenced"
:
selectedTab
;
const
hasBothTabs
=
referencingRelations
.
length
>
0
&&
referencedRelations
.
length
>
0
;
const
defaultTab
=
referencingRelations
.
length
>
0
?
"referencing"
:
"referenced"
;
const
tab
=
hasBothTabs
?
activeTab
:
defaultTab
;
const
isReferencing
=
tab
===
"referencing"
;
const
icon
=
isReferencing
?
LinkIcon
:
MilestoneIcon
;
const
activeRelations
=
isReferencing
?
referencingRelations
:
referencedRelations
;
return
(
<
MetadataCard
className=
{
className
}
>
<
div
className=
"w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60"
>
{
referencingRelations
.
length
>
0
&&
(
<
button
className=
{
cn
(
"w-auto flex flex-row justify-start items-center text-xs gap-0.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-1 py-0.5 transition-colors"
,
activeTab
===
"referencing"
&&
"text-foreground bg-accent"
,
)
}
onClick=
{
()
=>
setSelectedTab
(
"referencing"
)
}
>
<
LinkIcon
className=
"w-3 h-auto shrink-0 opacity-70"
/>
<
span
>
{
t
(
"common.referencing"
)
}
</
span
>
<
span
className=
"opacity-80"
>
(
{
referencingRelations
.
length
}
)
</
span
>
</
button
>
)
}
{
referencedRelations
.
length
>
0
&&
(
<
button
className=
{
cn
(
"w-auto flex flex-row justify-start items-center text-xs gap-0.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-1 py-0.5 transition-colors"
,
activeTab
===
"referenced"
&&
"text-foreground bg-accent"
,
)
}
onClick=
{
()
=>
setSelectedTab
(
"referenced"
)
}
>
<
MilestoneIcon
className=
"w-3 h-auto shrink-0 opacity-70"
/>
<
span
>
{
t
(
"common.referenced-by"
)
}
</
span
>
<
span
className=
"opacity-80"
>
(
{
referencedRelations
.
length
}
)
</
span
>
</
button
>
)
}
</
div
>
<
div
className=
{
cn
(
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
,
className
)
}
>
<
SectionHeader
icon=
{
icon
}
title=
{
isReferencing
?
t
(
"common.referencing"
)
:
t
(
"common.referenced-by"
)
}
count=
{
activeRelations
.
length
}
tabs=
{
hasBothTabs
?
[
{
id
:
"referencing"
,
label
:
t
(
"common.referencing"
),
count
:
referencingRelations
.
length
,
active
:
isReferencing
,
onClick
:
()
=>
setActiveTab
(
"referencing"
),
},
{
id
:
"referenced"
,
label
:
t
(
"common.referenced-by"
),
count
:
referencedRelations
.
length
,
active
:
!
isReferencing
,
onClick
:
()
=>
setActiveTab
(
"referenced"
),
},
]
:
undefined
}
/>
{
activeTab
===
"referencing"
&&
referencingRelations
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-col justify-start items-start"
>
{
referencingRelations
.
map
((
relation
)
=>
(
<
RelationCard
key=
{
relation
.
relatedMemo
!
.
name
}
memo=
{
relation
.
relatedMemo
!
}
parentPage=
{
parentPage
}
/>
<
div
className=
"p-1.5 flex flex-col gap-0"
>
{
activeRelations
.
map
((
relation
)
=>
(
<
RelationCard
key=
{
isReferencing
?
relation
.
relatedMemo
!
.
name
:
relation
.
memo
!
.
name
}
memo=
{
isReferencing
?
relation
.
relatedMemo
!
:
relation
.
memo
!
}
parentPage=
{
parentPage
}
/>
))
}
</
div
>
)
}
{
activeTab
===
"referenced"
&&
referencedRelations
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-col justify-start items-start"
>
{
referencedRelations
.
map
((
relation
)
=>
(
<
RelationCard
key=
{
relation
.
memo
!
.
name
}
memo=
{
relation
.
memo
!
}
parentPage=
{
parentPage
}
/>
))
}
</
div
>
)
}
</
MetadataCard
>
);
}
...
...
web/src/components/MemoView/components/metadata/SectionHeader.tsx
0 → 100644
View file @
77e9376e
import
{
LucideIcon
}
from
"lucide-react"
;
import
{
cn
}
from
"@/lib/utils"
;
interface
SectionHeaderProps
{
icon
:
LucideIcon
;
title
:
string
;
count
:
number
;
tabs
?:
Array
<
{
id
:
string
;
label
:
string
;
count
:
number
;
active
:
boolean
;
onClick
:
()
=>
void
;
}
>
;
}
const
SectionHeader
=
({
icon
:
Icon
,
title
,
count
,
tabs
}:
SectionHeaderProps
)
=>
{
return
(
<
div
className=
"flex items-center gap-1.5 px-2 py-1 border-b border-border bg-muted/30"
>
<
Icon
className=
"w-3.5 h-3.5 text-muted-foreground"
/>
{
tabs
&&
tabs
.
length
>
1
?
(
<
div
className=
"flex items-center gap-0.5"
>
{
tabs
.
map
((
tab
,
idx
)
=>
(
<
div
key=
{
tab
.
id
}
className=
"flex items-center gap-0.5"
>
<
button
onClick=
{
tab
.
onClick
}
className=
{
cn
(
"text-xs font-medium px-0 py-0 transition-colors"
,
tab
.
active
?
"text-foreground"
:
"text-muted-foreground hover:text-foreground"
,
)
}
>
{
tab
.
label
}
(
{
tab
.
count
}
)
</
button
>
{
idx
<
tabs
.
length
-
1
&&
<
span
className=
"text-muted-foreground/50"
>
/
</
span
>
}
</
div
>
))
}
</
div
>
)
:
(
<
span
className=
"text-xs font-medium text-foreground"
>
{
title
}
(
{
count
}
)
</
span
>
)
}
</
div
>
);
};
export
default
SectionHeader
;
web/src/components/MemoView/components/metadata/index.ts
View file @
77e9376e
export
{
default
as
AttachmentCard
}
from
"./AttachmentCard"
;
export
{
default
as
AttachmentList
}
from
"./AttachmentList"
;
export
{
default
as
LocationDisplay
}
from
"./LocationDisplay"
;
export
{
default
as
MetadataCard
}
from
"./MetadataCard"
;
export
{
default
as
RelationCard
}
from
"./RelationCard"
;
export
{
default
as
RelationList
}
from
"./RelationList"
;
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