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
f403f8c0
Commit
f403f8c0
authored
Apr 02, 2026
by
memoclaw
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor: simplify memo metadata components
parent
0e4d2d25
Changes
15
Hide whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
266 additions
and
180 deletions
+266
-180
VoiceRecorderPanel.tsx
...c/components/MemoEditor/components/VoiceRecorderPanel.tsx
+1
-1
AttachmentListEditor.tsx
...mponents/MemoMetadata/Attachment/AttachmentListEditor.tsx
+19
-23
AttachmentListView.tsx
...components/MemoMetadata/Attachment/AttachmentListView.tsx
+13
-21
AudioAttachmentItem.tsx
...omponents/MemoMetadata/Attachment/AudioAttachmentItem.tsx
+1
-1
attachmentHelpers.ts
...c/components/MemoMetadata/Attachment/attachmentHelpers.ts
+18
-21
LocationDisplayEditor.tsx
...omponents/MemoMetadata/Location/LocationDisplayEditor.tsx
+3
-4
LocationDisplayView.tsx
.../components/MemoMetadata/Location/LocationDisplayView.tsx
+6
-7
locationHelpers.ts
web/src/components/MemoMetadata/Location/locationHelpers.ts
+9
-0
MetadataSection.tsx
web/src/components/MemoMetadata/MetadataSection.tsx
+24
-0
RelationListEditor.tsx
...c/components/MemoMetadata/Relation/RelationListEditor.tsx
+18
-41
RelationListView.tsx
...src/components/MemoMetadata/Relation/RelationListView.tsx
+39
-53
relationHelpers.ts
web/src/components/MemoMetadata/Relation/relationHelpers.ts
+42
-0
useResolvedRelationMemos.ts
...ponents/MemoMetadata/Relation/useResolvedRelationMemos.ts
+61
-0
SectionHeader.tsx
web/src/components/MemoMetadata/SectionHeader.tsx
+11
-8
index.ts
web/src/components/MemoMetadata/index.ts
+1
-0
No files found.
web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx
View file @
f403f8c0
import
{
AudioLinesIcon
,
LoaderCircleIcon
,
MicIcon
,
RotateCcwIcon
,
SquareIcon
,
Trash2Icon
}
from
"lucide-react"
;
import
{
AudioLinesIcon
,
LoaderCircleIcon
,
MicIcon
,
RotateCcwIcon
,
SquareIcon
,
Trash2Icon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
type
{
FC
}
from
"react"
;
import
{
AudioAttachmentItem
}
from
"@/components/MemoMetadata/Attachment"
;
import
{
AudioAttachmentItem
}
from
"@/components/MemoMetadata/Attachment"
;
import
{
formatAudioTime
}
from
"@/components/MemoMetadata/Attachment/attachment
View
Helpers"
;
import
{
formatAudioTime
}
from
"@/components/MemoMetadata/Attachment/attachmentHelpers"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
...
...
web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx
View file @
f403f8c0
...
@@ -2,10 +2,10 @@ import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "
...
@@ -2,10 +2,10 @@ import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "
import
type
{
FC
}
from
"react"
;
import
type
{
FC
}
from
"react"
;
import
type
{
AttachmentItem
,
LocalFile
}
from
"@/components/MemoEditor/types/attachment"
;
import
type
{
AttachmentItem
,
LocalFile
}
from
"@/components/MemoEditor/types/attachment"
;
import
{
toAttachmentItems
}
from
"@/components/MemoEditor/types/attachment"
;
import
{
toAttachmentItems
}
from
"@/components/MemoEditor/types/attachment"
;
import
MetadataSection
from
"@/components/MemoMetadata/MetadataSection"
;
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
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
import
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
import
SectionHeader
from
"../SectionHeader"
;
interface
AttachmentListEditorProps
{
interface
AttachmentListEditorProps
{
attachments
:
Attachment
[];
attachments
:
Attachment
[];
...
@@ -142,28 +142,24 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({ attachments, loca
...
@@ -142,28 +142,24 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({ attachments, loca
};
};
return
(
return
(
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
MetadataSection
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
items
.
length
}
contentClassName=
"flex flex-col gap-0.5 p-1 sm:p-1.5"
>
<
SectionHeader
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
items
.
length
}
/>
{
items
.
map
((
item
)
=>
{
const
isLocalFile
=
item
.
isLocal
;
<
div
className=
"p-1 sm:p-1.5 flex flex-col gap-0.5"
>
const
attachmentIndex
=
isLocalFile
?
-
1
:
attachments
.
findIndex
((
a
)
=>
a
.
name
===
item
.
id
);
{
items
.
map
((
item
)
=>
{
const
isLocalFile
=
item
.
isLocal
;
return
(
const
attachmentIndex
=
isLocalFile
?
-
1
:
attachments
.
findIndex
((
a
)
=>
a
.
name
===
item
.
id
);
<
AttachmentItemCard
key=
{
item
.
id
}
return
(
item=
{
item
}
<
AttachmentItemCard
onRemove=
{
()
=>
handleRemoveItem
(
item
)
}
key=
{
item
.
id
}
onMoveUp=
{
!
isLocalFile
?
()
=>
handleMoveUp
(
attachmentIndex
)
:
undefined
}
item=
{
item
}
onMoveDown=
{
!
isLocalFile
?
()
=>
handleMoveDown
(
attachmentIndex
)
:
undefined
}
onRemove=
{
()
=>
handleRemoveItem
(
item
)
}
canMoveUp=
{
!
isLocalFile
&&
attachmentIndex
>
0
}
onMoveUp=
{
!
isLocalFile
?
()
=>
handleMoveUp
(
attachmentIndex
)
:
undefined
}
canMoveDown=
{
!
isLocalFile
&&
attachmentIndex
<
attachments
.
length
-
1
}
onMoveDown=
{
!
isLocalFile
?
()
=>
handleMoveDown
(
attachmentIndex
)
:
undefined
}
/>
canMoveUp=
{
!
isLocalFile
&&
attachmentIndex
>
0
}
);
canMoveDown=
{
!
isLocalFile
&&
attachmentIndex
<
attachments
.
length
-
1
}
})
}
/>
</
MetadataSection
>
);
})
}
</
div
>
</
div
>
);
);
};
};
...
...
web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx
View file @
f403f8c0
import
{
DownloadIcon
,
FileIcon
,
Maximize2Icon
,
PaperclipIcon
,
PlayIcon
}
from
"lucide-react"
;
import
{
DownloadIcon
,
FileIcon
,
Maximize2Icon
,
PaperclipIcon
,
PlayIcon
}
from
"lucide-react"
;
import
{
useMemo
}
from
"react"
;
import
{
useMemo
}
from
"react"
;
import
MetadataSection
from
"@/components/MemoMetadata/MetadataSection"
;
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
{
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
{
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
SectionHeader
from
"../SectionHeader"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
AudioAttachmentItem
from
"./AudioAttachmentItem"
;
import
AudioAttachmentItem
from
"./AudioAttachmentItem"
;
import
{
getAttachmentMetadata
,
isImageAttachment
,
isVideoAttachment
,
separateAttachments
}
from
"./attachment
View
Helpers"
;
import
{
getAttachmentMetadata
,
isImageAttachment
,
isVideoAttachment
,
separateAttachments
}
from
"./attachmentHelpers"
;
interface
AttachmentListViewProps
{
interface
AttachmentListViewProps
{
attachments
:
Attachment
[];
attachments
:
Attachment
[];
...
@@ -172,9 +172,12 @@ const Divider = () => <div className="border-t border-border/70 opacity-80" />;
...
@@ -172,9 +172,12 @@ const Divider = () => <div className="border-t border-border/70 opacity-80" />;
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
]);
const
imageAttachments
=
useMemo
(()
=>
visual
.
filter
(
isImageAttachment
),
[
visual
]);
const
imageAttachments
=
useMemo
(()
=>
visual
.
filter
(
isImageAttachment
),
[
visual
]);
const
imageUrls
=
useMemo
(()
=>
imageAttachments
.
map
(
getAttachmentUrl
),
[
imageAttachments
]);
const
imageUrls
=
useMemo
(()
=>
imageAttachments
.
map
(
getAttachmentUrl
),
[
imageAttachments
]);
const
hasVisual
=
visual
.
length
>
0
;
const
hasAudio
=
audio
.
length
>
0
;
const
hasDocs
=
docs
.
length
>
0
;
const
sectionCount
=
[
hasVisual
,
hasAudio
,
hasDocs
].
filter
(
Boolean
).
length
;
if
(
attachments
.
length
===
0
)
{
if
(
attachments
.
length
===
0
)
{
return
null
;
return
null
;
...
@@ -185,25 +188,14 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
...
@@ -185,25 +188,14 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
onImagePreview
?.(
imageUrls
,
index
>=
0
?
index
:
0
);
onImagePreview
?.(
imageUrls
,
index
>=
0
?
index
:
0
);
};
};
const
sections
=
[
visual
.
length
>
0
,
audio
.
length
>
0
,
docs
.
length
>
0
];
const
sectionCount
=
sections
.
filter
(
Boolean
).
length
;
return
(
return
(
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
MetadataSection
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
attachments
.
length
}
contentClassName=
"flex flex-col gap-2 p-2"
>
<
SectionHeader
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
attachments
.
length
}
/>
{
hasVisual
&&
<
VisualSection
attachments=
{
visual
}
onImageClick=
{
handleImageClick
}
/>
}
{
hasVisual
&&
sectionCount
>
1
&&
<
Divider
/>
}
<
div
className=
"flex flex-col gap-2 p-2"
>
{
hasAudio
&&
<
AudioList
attachments=
{
audio
}
/>
}
{
visual
.
length
>
0
&&
<
VisualSection
attachments=
{
visual
}
onImageClick=
{
handleImageClick
}
/>
}
{
hasAudio
&&
hasDocs
&&
<
Divider
/>
}
{
hasDocs
&&
<
DocsList
attachments=
{
docs
}
/>
}
{
visual
.
length
>
0
&&
sectionCount
>
1
&&
<
Divider
/>
}
</
MetadataSection
>
{
audio
.
length
>
0
&&
<
AudioList
attachments=
{
audio
}
/>
}
{
audio
.
length
>
0
&&
docs
.
length
>
0
&&
<
Divider
/>
}
{
docs
.
length
>
0
&&
<
DocsList
attachments=
{
docs
}
/>
}
</
div
>
</
div
>
);
);
};
};
...
...
web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx
View file @
f403f8c0
import
{
FileAudioIcon
,
PauseIcon
,
PlayIcon
}
from
"lucide-react"
;
import
{
FileAudioIcon
,
PauseIcon
,
PlayIcon
}
from
"lucide-react"
;
import
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
import
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
import
{
formatAudioTime
}
from
"./attachment
View
Helpers"
;
import
{
formatAudioTime
}
from
"./attachmentHelpers"
;
const
AUDIO_PLAYBACK_RATES
=
[
1
,
1.5
,
2
]
as
const
;
const
AUDIO_PLAYBACK_RATES
=
[
1
,
1.5
,
2
]
as
const
;
...
...
web/src/components/MemoMetadata/Attachment/attachment
View
Helpers.ts
→
web/src/components/MemoMetadata/Attachment/attachmentHelpers.ts
View file @
f403f8c0
...
@@ -18,27 +18,24 @@ export const isVideoAttachment = (attachment: Attachment): boolean => getAttachm
...
@@ -18,27 +18,24 @@ export const isVideoAttachment = (attachment: Attachment): boolean => getAttachm
export
const
isAudioAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"audio/*"
;
export
const
isAudioAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"audio/*"
;
export
const
separateAttachments
=
(
attachments
:
Attachment
[]):
AttachmentGroups
=>
{
export
const
separateAttachments
=
(
attachments
:
Attachment
[]):
AttachmentGroups
=>
{
const
groups
:
AttachmentGroups
=
{
return
attachments
.
reduce
<
AttachmentGroups
>
(
visual
:
[],
(
groups
,
attachment
)
=>
{
audio
:
[],
if
(
isImageAttachment
(
attachment
)
||
isVideoAttachment
(
attachment
))
{
docs
:
[],
groups
.
visual
.
push
(
attachment
);
};
}
else
if
(
isAudioAttachment
(
attachment
))
{
groups
.
audio
.
push
(
attachment
);
for
(
const
attachment
of
attachments
)
{
}
else
{
if
(
isImageAttachment
(
attachment
)
||
isVideoAttachment
(
attachment
))
{
groups
.
docs
.
push
(
attachment
);
groups
.
visual
.
push
(
attachment
);
}
continue
;
}
return
groups
;
},
if
(
isAudioAttachment
(
attachment
))
{
{
groups
.
audio
.
push
(
attachment
);
visual
:
[],
continue
;
audio
:
[],
}
docs
:
[],
},
groups
.
docs
.
push
(
attachment
);
);
}
return
groups
;
};
};
export
const
getAttachmentMetadata
=
(
attachment
:
Attachment
):
AttachmentMetadata
=>
({
export
const
getAttachmentMetadata
=
(
attachment
:
Attachment
):
AttachmentMetadata
=>
({
...
...
web/src/components/MemoMetadata/Location/LocationDisplayEditor.tsx
View file @
f403f8c0
...
@@ -2,6 +2,7 @@ import { MapPinIcon, XIcon } from "lucide-react";
...
@@ -2,6 +2,7 @@ import { MapPinIcon, XIcon } from "lucide-react";
import
type
{
FC
}
from
"react"
;
import
type
{
FC
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
Location
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
type
{
Location
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
getLocationCoordinatesText
,
getLocationDisplayText
}
from
"./locationHelpers"
;
interface
LocationDisplayEditorProps
{
interface
LocationDisplayEditorProps
{
location
:
Location
;
location
:
Location
;
...
@@ -10,7 +11,7 @@ interface LocationDisplayEditorProps {
...
@@ -10,7 +11,7 @@ interface LocationDisplayEditorProps {
}
}
const
LocationDisplayEditor
:
FC
<
LocationDisplayEditorProps
>
=
({
location
,
onRemove
,
className
})
=>
{
const
LocationDisplayEditor
:
FC
<
LocationDisplayEditorProps
>
=
({
location
,
onRemove
,
className
})
=>
{
const
displayText
=
location
.
placeholder
||
`
${
location
.
latitude
.
toFixed
(
6
)}
,
${
location
.
longitude
.
toFixed
(
6
)}
`
;
const
displayText
=
getLocationDisplayText
(
location
)
;
return
(
return
(
<
div
<
div
...
@@ -25,9 +26,7 @@ const LocationDisplayEditor: FC<LocationDisplayEditorProps> = ({ location, onRem
...
@@ -25,9 +26,7 @@ const LocationDisplayEditor: FC<LocationDisplayEditorProps> = ({ location, onRem
<
span
className=
"text-xs truncate"
title=
{
displayText
}
>
<
span
className=
"text-xs truncate"
title=
{
displayText
}
>
{
displayText
}
{
displayText
}
</
span
>
</
span
>
<
span
className=
"text-[11px] text-muted-foreground shrink-0 hidden sm:inline"
>
<
span
className=
"text-[11px] text-muted-foreground shrink-0 hidden sm:inline"
>
{
getLocationCoordinatesText
(
location
)
}
</
span
>
{
location
.
latitude
.
toFixed
(
4
)
}
°,
{
location
.
longitude
.
toFixed
(
4
)
}
°
</
span
>
</
div
>
</
div
>
{
onRemove
&&
(
{
onRemove
&&
(
...
...
web/src/components/MemoMetadata/Location/LocationDisplayView.tsx
View file @
f403f8c0
...
@@ -5,6 +5,7 @@ import { LocationPicker } from "@/components/map";
...
@@ -5,6 +5,7 @@ import { LocationPicker } from "@/components/map";
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"@/components/ui/popover"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"@/components/ui/popover"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
Location
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
type
{
Location
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
getLocationCoordinatesText
,
getLocationDisplayText
}
from
"./locationHelpers"
;
interface
LocationDisplayViewProps
{
interface
LocationDisplayViewProps
{
location
?:
Location
;
location
?:
Location
;
...
@@ -18,27 +19,25 @@ const LocationDisplayView = ({ location, className }: LocationDisplayViewProps)
...
@@ -18,27 +19,25 @@ const LocationDisplayView = ({ location, className }: LocationDisplayViewProps)
return
null
;
return
null
;
}
}
const
displayText
=
location
.
placeholder
||
`Position: [
${
location
.
latitude
}
,
${
location
.
longitude
}
]`
;
const
displayText
=
getLocationDisplayText
(
location
)
;
return
(
return
(
<
Popover
open=
{
popoverOpen
}
onOpenChange=
{
setPopoverOpen
}
>
<
Popover
open=
{
popoverOpen
}
onOpenChange=
{
setPopoverOpen
}
>
<
PopoverTrigger
asChild
>
<
PopoverTrigger
asChild
>
<
div
<
button
type=
"button"
className=
{
cn
(
className=
{
cn
(
"w-full flex flex-row gap-2 cursor-pointer"
,
"w-full flex flex-row gap-2 cursor-pointer"
,
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-muted/20 hover:bg-accent/20 text-muted-foreground hover:text-foreground text-xs transition-colors"
,
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-muted/20 hover:bg-accent/20 text-muted-foreground hover:text-foreground text-xs transition-colors"
,
className
,
className
,
)
}
)
}
onClick=
{
()
=>
setPopoverOpen
(
true
)
}
>
>
<
span
className=
"shrink-0 text-muted-foreground"
>
<
span
className=
"shrink-0 text-muted-foreground"
>
<
MapPinIcon
className=
"w-3.5 h-3.5"
/>
<
MapPinIcon
className=
"w-3.5 h-3.5"
/>
</
span
>
</
span
>
<
span
className=
"text-nowrap opacity-80"
>
<
span
className=
"text-nowrap opacity-80"
>
[
{
getLocationCoordinatesText
(
location
,
2
)
}
]
</
span
>
[
{
location
.
latitude
.
toFixed
(
2
)
}
°,
{
location
.
longitude
.
toFixed
(
2
)
}
°]
</
span
>
<
span
className=
"text-nowrap truncate"
>
{
displayText
}
</
span
>
<
span
className=
"text-nowrap truncate"
>
{
displayText
}
</
span
>
</
div
>
</
button
>
</
PopoverTrigger
>
</
PopoverTrigger
>
<
PopoverContent
align=
"start"
>
<
PopoverContent
align=
"start"
>
<
div
className=
"min-w-80 sm:w-lg flex flex-col justify-start items-start"
>
<
div
className=
"min-w-80 sm:w-lg flex flex-col justify-start items-start"
>
...
...
web/src/components/MemoMetadata/Location/locationHelpers.ts
0 → 100644
View file @
f403f8c0
import
type
{
Location
}
from
"@/types/proto/api/v1/memo_service_pb"
;
export
const
getLocationDisplayText
=
(
location
:
Location
):
string
=>
{
return
location
.
placeholder
||
`
${
location
.
latitude
.
toFixed
(
6
)}
,
${
location
.
longitude
.
toFixed
(
6
)}
`
;
};
export
const
getLocationCoordinatesText
=
(
location
:
Location
,
digits
=
4
):
string
=>
{
return
`
${
location
.
latitude
.
toFixed
(
digits
)}
°,
${
location
.
longitude
.
toFixed
(
digits
)}
°`
;
};
web/src/components/MemoMetadata/MetadataSection.tsx
0 → 100644
View file @
f403f8c0
import
type
{
LucideIcon
}
from
"lucide-react"
;
import
type
{
PropsWithChildren
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
SectionHeader
,
{
type
SectionHeaderTab
}
from
"./SectionHeader"
;
interface
MetadataSectionProps
extends
PropsWithChildren
{
icon
:
LucideIcon
;
title
:
string
;
count
:
number
;
tabs
?:
SectionHeaderTab
[];
className
?:
string
;
contentClassName
?:
string
;
}
const
MetadataSection
=
({
icon
,
title
,
count
,
tabs
,
className
,
contentClassName
,
children
}:
MetadataSectionProps
)
=>
{
return
(
<
div
className=
{
cn
(
"w-full overflow-hidden rounded-lg border border-border bg-muted/20"
,
className
)
}
>
<
SectionHeader
icon=
{
icon
}
title=
{
title
}
count=
{
count
}
tabs=
{
tabs
}
/>
<
div
className=
{
contentClassName
}
>
{
children
}
</
div
>
</
div
>
);
};
export
default
MetadataSection
;
web/src/components/MemoMetadata/Relation/RelationListEditor.tsx
View file @
f403f8c0
import
{
create
}
from
"@bufbuild/protobuf"
;
import
{
LinkIcon
,
XIcon
}
from
"lucide-react"
;
import
{
LinkIcon
,
XIcon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
type
{
FC
}
from
"react"
;
import
{
use
Effect
,
useMemo
,
useState
}
from
"react"
;
import
{
use
Memo
}
from
"react"
;
import
{
memoServiceClient
}
from
"@/connect
"
;
import
MetadataSection
from
"@/components/MemoMetadata/MetadataSection
"
;
import
type
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
type
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
MemoRelation_Memo
,
MemoRelation_MemoSchema
,
MemoRelation_Type
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
SectionHeader
from
"../SectionHeader"
;
import
RelationCard
from
"./RelationCard"
;
import
RelationCard
from
"./RelationCard"
;
import
{
getEditorReferenceRelations
}
from
"./relationHelpers"
;
import
{
useResolvedRelationMemos
}
from
"./useResolvedRelationMemos"
;
interface
RelationListEditorProps
{
interface
RelationListEditorProps
{
relations
:
MemoRelation
[];
relations
:
MemoRelation
[];
...
@@ -40,31 +39,8 @@ const RelationItemCard: FC<{
...
@@ -40,31 +39,8 @@ const RelationItemCard: FC<{
};
};
const
RelationListEditor
:
FC
<
RelationListEditorProps
>
=
({
relations
,
onRelationsChange
,
parentPage
,
memoName
})
=>
{
const
RelationListEditor
:
FC
<
RelationListEditorProps
>
=
({
relations
,
onRelationsChange
,
parentPage
,
memoName
})
=>
{
const
referenceRelations
=
useMemo
(
const
referenceRelations
=
useMemo
(()
=>
getEditorReferenceRelations
(
relations
,
memoName
),
[
relations
,
memoName
]);
()
=>
relations
.
filter
((
r
)
=>
r
.
type
===
MemoRelation_Type
.
REFERENCE
&&
(
!
memoName
||
!
r
.
memo
?.
name
||
r
.
memo
.
name
===
memoName
)),
const
resolvedMemos
=
useResolvedRelationMemos
(
referenceRelations
);
[
relations
,
memoName
],
);
const
[
fetchedMemos
,
setFetchedMemos
]
=
useState
<
Record
<
string
,
MemoRelation_Memo
>>
({});
useEffect
(()
=>
{
(
async
()
=>
{
const
missingSnippetRelations
=
referenceRelations
.
filter
((
relation
)
=>
!
relation
.
relatedMemo
?.
snippet
&&
relation
.
relatedMemo
?.
name
);
if
(
missingSnippetRelations
.
length
>
0
)
{
const
requests
=
missingSnippetRelations
.
map
(
async
(
relation
)
=>
{
const
memo
=
await
memoServiceClient
.
getMemo
({
name
:
relation
.
relatedMemo
!
.
name
});
return
create
(
MemoRelation_MemoSchema
,
{
name
:
memo
.
name
,
snippet
:
memo
.
snippet
});
});
const
list
=
await
Promise
.
all
(
requests
);
setFetchedMemos
((
prev
)
=>
{
const
next
=
{
...
prev
};
for
(
const
memo
of
list
)
{
next
[
memo
.
name
]
=
memo
;
}
return
next
;
});
}
})();
},
[
referenceRelations
]);
const
handleDeleteRelation
=
(
memoName
:
string
)
=>
{
const
handleDeleteRelation
=
(
memoName
:
string
)
=>
{
if
(
onRelationsChange
)
{
if
(
onRelationsChange
)
{
...
@@ -77,17 +53,18 @@ const RelationListEditor: FC<RelationListEditorProps> = ({ relations, onRelation
...
@@ -77,17 +53,18 @@ const RelationListEditor: FC<RelationListEditorProps> = ({ relations, onRelation
}
}
return
(
return
(
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
MetadataSection
<
SectionHeader
icon=
{
LinkIcon
}
title=
"Relations"
count=
{
referenceRelations
.
length
}
/>
icon=
{
LinkIcon
}
title=
"Relations"
<
div
className=
"p-1 sm:p-1.5 flex flex-col gap-0.5"
>
count=
{
referenceRelations
.
length
}
{
referenceRelations
.
map
((
relation
)
=>
{
contentClassName=
"flex flex-col gap-0.5 p-1 sm:p-1.5"
const
relatedMemo
=
relation
.
relatedMemo
!
;
>
const
memo
=
relatedMemo
.
snippet
?
relatedMemo
:
fetchedMemos
[
relatedMemo
.
name
]
||
relatedMemo
;
{
referenceRelations
.
map
((
relation
)
=>
{
return
<
RelationItemCard
key=
{
memo
.
name
}
memo=
{
memo
}
onRemove=
{
()
=>
handleDeleteRelation
(
memo
.
name
)
}
parentPage=
{
parentPage
}
/>;
const
relatedMemo
=
relation
.
relatedMemo
!
;
})
}
const
memo
=
relatedMemo
.
snippet
?
relatedMemo
:
resolvedMemos
[
relatedMemo
.
name
]
||
relatedMemo
;
</
div
>
return
<
RelationItemCard
key=
{
memo
.
name
}
memo=
{
memo
}
onRemove=
{
()
=>
handleDeleteRelation
(
memo
.
name
)
}
parentPage=
{
parentPage
}
/>;
</
div
>
})
}
</
MetadataSection
>
);
);
};
};
...
...
web/src/components/MemoMetadata/Relation/RelationListView.tsx
View file @
f403f8c0
import
{
LinkIcon
,
MilestoneIcon
}
from
"lucide-react"
;
import
{
LinkIcon
,
MilestoneIcon
}
from
"lucide-react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils
"
;
import
MetadataSection
from
"@/components/MemoMetadata/MetadataSection
"
;
import
type
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
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
{
useTranslate
}
from
"@/utils/i18n"
;
import
SectionHeader
from
"../SectionHeader"
;
import
RelationCard
from
"./RelationCard"
;
import
RelationCard
from
"./RelationCard"
;
import
{
getRelationBuckets
,
getRelationMemo
,
getRelationMemoName
,
type
RelationDirection
}
from
"./relationHelpers"
;
interface
RelationListViewProps
{
interface
RelationListViewProps
{
relations
:
MemoRelation
[];
relations
:
MemoRelation
[];
...
@@ -18,66 +17,53 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
...
@@ -18,66 +17,53 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
[
activeTab
,
setActiveTab
]
=
useState
<
"referencing"
|
"referenced"
>
(
"referencing"
);
const
[
activeTab
,
setActiveTab
]
=
useState
<
"referencing"
|
"referenced"
>
(
"referencing"
);
const
{
referencingRelations
,
referencedRelations
}
=
useMemo
(()
=>
{
const
{
referencing
:
referencingRelations
,
referenced
:
referencedRelations
}
=
useMemo
(
return
{
()
=>
getRelationBuckets
(
relations
,
currentMemoName
),
referencingRelations
:
relations
.
filter
(
[
relations
,
currentMemoName
],
(
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
)
{
if
(
referencingRelations
.
length
===
0
&&
referencedRelations
.
length
===
0
)
{
return
null
;
return
null
;
}
}
const
hasBothTabs
=
referencingRelations
.
length
>
0
&&
referencedRelations
.
length
>
0
;
const
hasBothTabs
=
referencingRelations
.
length
>
0
&&
referencedRelations
.
length
>
0
;
const
defaultTab
=
referencingRelations
.
length
>
0
?
"referencing"
:
"referenced"
;
const
direction
:
RelationDirection
=
hasBothTabs
?
activeTab
:
referencingRelations
.
length
>
0
?
"referencing"
:
"referenced"
;
const
tab
=
hasBothTabs
?
activeTab
:
defaultTab
;
const
isReferencing
=
direction
===
"referencing"
;
const
isReferencing
=
tab
===
"referencing"
;
const
icon
=
isReferencing
?
LinkIcon
:
MilestoneIcon
;
const
icon
=
isReferencing
?
LinkIcon
:
MilestoneIcon
;
const
activeRelations
=
isReferencing
?
referencingRelations
:
referencedRelations
;
const
activeRelations
=
isReferencing
?
referencingRelations
:
referencedRelations
;
return
(
return
(
<
div
className=
{
cn
(
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
,
className
)
}
>
<
MetadataSection
<
SectionHeader
className=
{
className
}
icon=
{
icon
}
icon=
{
icon
}
title=
{
isReferencing
?
t
(
"common.referencing"
)
:
t
(
"common.referenced-by"
)
}
title=
{
isReferencing
?
t
(
"common.referencing"
)
:
t
(
"common.referenced-by"
)
}
count=
{
activeRelations
.
length
}
count=
{
activeRelations
.
length
}
tabs=
{
tabs=
{
hasBothTabs
hasBothTabs
?
[
?
[
{
{
id
:
"referencing"
,
id
:
"referencing"
,
label
:
t
(
"common.referencing"
),
label
:
t
(
"common.referencing"
),
count
:
referencingRelations
.
length
,
count
:
referencingRelations
.
length
,
active
:
isReferencing
,
active
:
isReferencing
,
onClick
:
()
=>
setActiveTab
(
"referencing"
),
onClick
:
()
=>
setActiveTab
(
"referencing"
),
},
},
{
{
id
:
"referenced"
,
id
:
"referenced"
,
label
:
t
(
"common.referenced-by"
),
label
:
t
(
"common.referenced-by"
),
count
:
referencedRelations
.
length
,
count
:
referencedRelations
.
length
,
active
:
!
isReferencing
,
active
:
!
isReferencing
,
onClick
:
()
=>
setActiveTab
(
"referenced"
),
onClick
:
()
=>
setActiveTab
(
"referenced"
),
},
},
]
]
:
undefined
:
undefined
}
}
/>
contentClassName=
"flex flex-col gap-0 p-1.5"
>
<
div
className=
"p-1.5 flex flex-col gap-0"
>
{
activeRelations
.
map
((
relation
)
=>
(
{
activeRelations
.
map
((
relation
)
=>
(
<
RelationCard
key=
{
getRelationMemoName
(
relation
,
direction
)
}
memo=
{
getRelationMemo
(
relation
,
direction
)
!
}
parentPage=
{
parentPage
}
/>
<
RelationCard
))
}
key=
{
isReferencing
?
relation
.
relatedMemo
!
.
name
:
relation
.
memo
!
.
name
}
</
MetadataSection
>
memo=
{
isReferencing
?
relation
.
relatedMemo
!
:
relation
.
memo
!
}
parentPage=
{
parentPage
}
/>
))
}
</
div
>
</
div
>
);
);
}
}
...
...
web/src/components/MemoMetadata/Relation/relationHelpers.ts
0 → 100644
View file @
f403f8c0
import
type
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
MemoRelation_Type
}
from
"@/types/proto/api/v1/memo_service_pb"
;
export
type
RelationDirection
=
"referencing"
|
"referenced"
;
export
const
isReferenceRelation
=
(
relation
:
MemoRelation
):
boolean
=>
relation
.
type
===
MemoRelation_Type
.
REFERENCE
;
export
const
getEditorReferenceRelations
=
(
relations
:
MemoRelation
[],
memoName
?:
string
):
MemoRelation
[]
=>
{
return
relations
.
filter
(
(
relation
)
=>
isReferenceRelation
(
relation
)
&&
(
!
memoName
||
!
relation
.
memo
?.
name
||
relation
.
memo
.
name
===
memoName
),
);
};
export
const
getRelationBuckets
=
(
relations
:
MemoRelation
[],
currentMemoName
?:
string
)
=>
{
return
relations
.
reduce
(
(
groups
,
relation
)
=>
{
if
(
!
isReferenceRelation
(
relation
))
{
return
groups
;
}
if
(
relation
.
memo
?.
name
===
currentMemoName
&&
relation
.
relatedMemo
?.
name
!==
currentMemoName
)
{
groups
.
referencing
.
push
(
relation
);
}
else
if
(
relation
.
memo
?.
name
!==
currentMemoName
&&
relation
.
relatedMemo
?.
name
===
currentMemoName
)
{
groups
.
referenced
.
push
(
relation
);
}
return
groups
;
},
{
referencing
:
[]
as
MemoRelation
[],
referenced
:
[]
as
MemoRelation
[],
},
);
};
export
const
getRelationMemo
=
(
relation
:
MemoRelation
,
direction
:
RelationDirection
)
=>
{
return
direction
===
"referencing"
?
relation
.
relatedMemo
:
relation
.
memo
;
};
export
const
getRelationMemoName
=
(
relation
:
MemoRelation
,
direction
:
RelationDirection
):
string
=>
{
return
getRelationMemo
(
relation
,
direction
)?.
name
??
""
;
};
web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts
0 → 100644
View file @
f403f8c0
import
{
create
}
from
"@bufbuild/protobuf"
;
import
{
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
memoServiceClient
}
from
"@/connect"
;
import
type
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
MemoRelation_Memo
,
MemoRelation_MemoSchema
}
from
"@/types/proto/api/v1/memo_service_pb"
;
export
const
useResolvedRelationMemos
=
(
relations
:
MemoRelation
[])
=>
{
const
[
resolvedMemos
,
setResolvedMemos
]
=
useState
<
Record
<
string
,
MemoRelation_Memo
>>
({});
const
missingMemoNames
=
useMemo
(()
=>
{
const
names
=
new
Set
<
string
>
();
for
(
const
relation
of
relations
)
{
const
relatedMemo
=
relation
.
relatedMemo
;
if
(
relatedMemo
?.
name
&&
!
relatedMemo
.
snippet
&&
!
resolvedMemos
[
relatedMemo
.
name
])
{
names
.
add
(
relatedMemo
.
name
);
}
}
return
[...
names
];
},
[
relations
,
resolvedMemos
]);
useEffect
(()
=>
{
if
(
missingMemoNames
.
length
===
0
)
{
return
;
}
let
cancelled
=
false
;
void
(
async
()
=>
{
try
{
const
memos
=
await
Promise
.
all
(
missingMemoNames
.
map
(
async
(
name
)
=>
{
const
memo
=
await
memoServiceClient
.
getMemo
({
name
});
return
create
(
MemoRelation_MemoSchema
,
{
name
:
memo
.
name
,
snippet
:
memo
.
snippet
});
}),
);
if
(
cancelled
)
{
return
;
}
setResolvedMemos
((
prev
)
=>
{
const
next
=
{
...
prev
};
for
(
const
memo
of
memos
)
{
next
[
memo
.
name
]
=
memo
;
}
return
next
;
});
}
catch
{
// Keep existing relation data when snippet hydration fails.
}
})();
return
()
=>
{
cancelled
=
true
;
};
},
[
missingMemoNames
]);
return
resolvedMemos
;
};
web/src/components/MemoMetadata/SectionHeader.tsx
View file @
f403f8c0
import
{
LucideIcon
}
from
"lucide-react"
;
import
type
{
LucideIcon
}
from
"lucide-react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
export
interface
SectionHeaderTab
{
id
:
string
;
label
:
string
;
count
:
number
;
active
:
boolean
;
onClick
:
()
=>
void
;
}
interface
SectionHeaderProps
{
interface
SectionHeaderProps
{
icon
:
LucideIcon
;
icon
:
LucideIcon
;
title
:
string
;
title
:
string
;
count
:
number
;
count
:
number
;
tabs
?:
Array
<
{
tabs
?:
SectionHeaderTab
[];
id
:
string
;
label
:
string
;
count
:
number
;
active
:
boolean
;
onClick
:
()
=>
void
;
}
>
;
}
}
const
SectionHeader
=
({
icon
:
Icon
,
title
,
count
,
tabs
}:
SectionHeaderProps
)
=>
{
const
SectionHeader
=
({
icon
:
Icon
,
title
,
count
,
tabs
}:
SectionHeaderProps
)
=>
{
...
@@ -24,6 +26,7 @@ const SectionHeader = ({ icon: Icon, title, count, tabs }: SectionHeaderProps) =
...
@@ -24,6 +26,7 @@ const SectionHeader = ({ icon: Icon, title, count, tabs }: SectionHeaderProps) =
{
tabs
.
map
((
tab
,
idx
)
=>
(
{
tabs
.
map
((
tab
,
idx
)
=>
(
<
div
key=
{
tab
.
id
}
className=
"flex items-center gap-0.5"
>
<
div
key=
{
tab
.
id
}
className=
"flex items-center gap-0.5"
>
<
button
<
button
type=
"button"
onClick=
{
tab
.
onClick
}
onClick=
{
tab
.
onClick
}
className=
{
cn
(
className=
{
cn
(
"text-xs px-0 py-0 transition-colors"
,
"text-xs px-0 py-0 transition-colors"
,
...
...
web/src/components/MemoMetadata/index.ts
View file @
f403f8c0
...
@@ -2,5 +2,6 @@
...
@@ -2,5 +2,6 @@
export
{
AttachmentCard
,
AttachmentListEditor
,
AttachmentListView
}
from
"./Attachment"
;
export
{
AttachmentCard
,
AttachmentListEditor
,
AttachmentListView
}
from
"./Attachment"
;
export
{
LocationDialog
,
LocationDisplayEditor
,
LocationDisplayView
}
from
"./Location"
;
export
{
LocationDialog
,
LocationDisplayEditor
,
LocationDisplayView
}
from
"./Location"
;
export
{
default
as
MetadataSection
}
from
"./MetadataSection"
;
export
{
LinkMemoDialog
,
RelationCard
,
RelationListEditor
,
RelationListView
}
from
"./Relation"
;
export
{
LinkMemoDialog
,
RelationCard
,
RelationListEditor
,
RelationListView
}
from
"./Relation"
;
export
{
default
as
SectionHeader
}
from
"./SectionHeader"
;
export
{
default
as
SectionHeader
}
from
"./SectionHeader"
;
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