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
fc43f865
Unverified
Commit
fc43f865
authored
Nov 08, 2025
by
Johnny
Committed by
GitHub
Nov 08, 2025
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore(web): unify Location/Attachments/Relations components (#5241)
Co-authored-by:
Claude
<
noreply@anthropic.com
>
parent
a2ccf6b2
Changes
17
Hide whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
624 additions
and
430 deletions
+624
-430
MemoAttachmentListView.tsx
web/src/components/MemoAttachmentListView.tsx
+0
-113
AttachmentListView.tsx
web/src/components/MemoEditor/AttachmentListView.tsx
+0
-71
LocationView.tsx
web/src/components/MemoEditor/LocationView.tsx
+0
-34
RelationListView.tsx
web/src/components/MemoEditor/RelationListView.tsx
+0
-55
index.tsx
web/src/components/MemoEditor/index.tsx
+5
-6
MemoLocationView.tsx
web/src/components/MemoLocationView.tsx
+0
-35
MemoRelationListView.tsx
web/src/components/MemoRelationListView.tsx
+0
-110
MemoView.tsx
web/src/components/MemoView.tsx
+4
-6
AttachmentCard.tsx
web/src/components/memo-metadata/AttachmentCard.tsx
+96
-0
AttachmentList.tsx
web/src/components/memo-metadata/AttachmentList.tsx
+144
-0
LocationDisplay.tsx
web/src/components/memo-metadata/LocationDisplay.tsx
+62
-0
MetadataBadge.tsx
web/src/components/memo-metadata/MetadataBadge.tsx
+46
-0
MetadataCard.tsx
web/src/components/memo-metadata/MetadataCard.tsx
+26
-0
RelationCard.tsx
web/src/components/memo-metadata/RelationCard.tsx
+61
-0
RelationList.tsx
web/src/components/memo-metadata/RelationList.tsx
+153
-0
index.ts
web/src/components/memo-metadata/index.ts
+17
-0
types.ts
web/src/components/memo-metadata/types.ts
+10
-0
No files found.
web/src/components/MemoAttachmentListView.tsx
deleted
100644 → 0
View file @
a2ccf6b2
import
{
memo
,
useState
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service"
;
import
{
getAttachmentThumbnailUrl
,
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
MemoAttachment
from
"./MemoAttachment"
;
import
PreviewImageDialog
from
"./PreviewImageDialog"
;
const
MemoAttachmentListView
=
({
attachments
=
[]
}:
{
attachments
:
Attachment
[]
})
=>
{
const
[
previewImage
,
setPreviewImage
]
=
useState
<
{
open
:
boolean
;
urls
:
string
[];
index
:
number
}
>
({
open
:
false
,
urls
:
[],
index
:
0
,
});
const
mediaAttachments
:
Attachment
[]
=
[];
const
otherAttachments
:
Attachment
[]
=
[];
attachments
.
forEach
((
attachment
)
=>
{
const
type
=
getAttachmentType
(
attachment
);
if
(
type
===
"image/*"
||
type
===
"video/*"
)
{
mediaAttachments
.
push
(
attachment
);
return
;
}
otherAttachments
.
push
(
attachment
);
});
const
handleImageClick
=
(
imgUrl
:
string
)
=>
{
const
imgUrls
=
mediaAttachments
.
filter
((
attachment
)
=>
getAttachmentType
(
attachment
)
===
"image/*"
)
.
map
((
attachment
)
=>
getAttachmentUrl
(
attachment
));
const
index
=
imgUrls
.
findIndex
((
url
)
=>
url
===
imgUrl
);
setPreviewImage
({
open
:
true
,
urls
:
imgUrls
,
index
});
};
const
MediaCard
=
({
attachment
,
className
}:
{
attachment
:
Attachment
;
className
?:
string
})
=>
{
const
type
=
getAttachmentType
(
attachment
);
const
attachmentUrl
=
getAttachmentUrl
(
attachment
);
const
attachmentThumbnailUrl
=
getAttachmentThumbnailUrl
(
attachment
);
if
(
type
===
"image/*"
)
{
return
(
<
img
className=
{
cn
(
"cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain transition-colors"
,
className
)
}
src=
{
attachmentThumbnailUrl
}
onError=
{
(
e
)
=>
{
// Fallback to original image if thumbnail fails
const
target
=
e
.
target
as
HTMLImageElement
;
if
(
target
.
src
.
includes
(
"?thumbnail=true"
))
{
console
.
warn
(
"Thumbnail failed, falling back to original image:"
,
attachmentUrl
);
target
.
src
=
attachmentUrl
;
}
}
}
onClick=
{
()
=>
handleImageClick
(
attachmentUrl
)
}
decoding=
"async"
loading=
"lazy"
/>
);
}
else
if
(
type
===
"video/*"
)
{
return
(
<
video
className=
{
cn
(
"cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain bg-muted transition-colors"
,
className
,
)
}
preload=
"metadata"
crossOrigin=
"anonymous"
src=
{
attachmentUrl
}
controls
/>
);
}
else
{
return
<></>;
}
};
const
MediaList
=
({
attachments
=
[]
}:
{
attachments
:
Attachment
[]
})
=>
{
const
cards
=
attachments
.
map
((
attachment
)
=>
(
<
div
key=
{
attachment
.
name
}
className=
"max-w-[60%] w-fit flex flex-col justify-start items-start shrink-0"
>
<
MediaCard
className=
"max-h-64 grow"
attachment=
{
attachment
}
/>
</
div
>
));
return
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
{
cards
}
</
div
>;
};
const
OtherList
=
({
attachments
=
[]
}:
{
attachments
:
Attachment
[]
})
=>
{
if
(
attachments
.
length
===
0
)
return
<></>;
return
(
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
{
otherAttachments
.
map
((
attachment
)
=>
(
<
MemoAttachment
key=
{
attachment
.
name
}
attachment=
{
attachment
}
/>
))
}
</
div
>
);
};
return
(
<>
{
mediaAttachments
.
length
>
0
&&
<
MediaList
attachments=
{
mediaAttachments
}
/>
}
<
OtherList
attachments=
{
otherAttachments
}
/>
<
PreviewImageDialog
open=
{
previewImage
.
open
}
onOpenChange=
{
(
open
)
=>
setPreviewImage
((
prev
)
=>
({
...
prev
,
open
}))
}
imgUrls=
{
previewImage
.
urls
}
initialIndex=
{
previewImage
.
index
}
/>
</>
);
};
export
default
memo
(
MemoAttachmentListView
);
web/src/components/MemoEditor/AttachmentListView.tsx
deleted
100644 → 0
View file @
a2ccf6b2
import
{
DndContext
,
closestCenter
,
MouseSensor
,
TouchSensor
,
useSensor
,
useSensors
,
DragEndEvent
}
from
"@dnd-kit/core"
;
import
{
arrayMove
,
SortableContext
,
verticalListSortingStrategy
}
from
"@dnd-kit/sortable"
;
import
{
FileIcon
,
XIcon
}
from
"lucide-react"
;
import
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service"
;
import
{
getAttachmentThumbnailUrl
,
getAttachmentType
}
from
"@/utils/attachment"
;
import
SortableItem
from
"./SortableItem"
;
interface
Props
{
attachmentList
:
Attachment
[];
setAttachmentList
:
(
attachmentList
:
Attachment
[])
=>
void
;
}
const
AttachmentListView
=
(
props
:
Props
)
=>
{
const
{
attachmentList
,
setAttachmentList
}
=
props
;
const
sensors
=
useSensors
(
useSensor
(
MouseSensor
),
useSensor
(
TouchSensor
));
const
handleDeleteAttachment
=
async
(
name
:
string
)
=>
{
setAttachmentList
(
attachmentList
.
filter
((
attachment
)
=>
attachment
.
name
!==
name
));
};
const
handleDragEnd
=
(
event
:
DragEndEvent
)
=>
{
const
{
active
,
over
}
=
event
;
if
(
over
&&
active
.
id
!==
over
.
id
)
{
const
oldIndex
=
attachmentList
.
findIndex
((
attachment
)
=>
attachment
.
name
===
active
.
id
);
const
newIndex
=
attachmentList
.
findIndex
((
attachment
)
=>
attachment
.
name
===
over
.
id
);
setAttachmentList
(
arrayMove
(
attachmentList
,
oldIndex
,
newIndex
));
}
};
return
(
<
DndContext
sensors=
{
sensors
}
collisionDetection=
{
closestCenter
}
onDragEnd=
{
handleDragEnd
}
>
<
SortableContext
items=
{
attachmentList
.
map
((
attachment
)
=>
attachment
.
name
)
}
strategy=
{
verticalListSortingStrategy
}
>
{
attachmentList
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-row justify-start flex-wrap gap-2 mt-2 max-h-[50vh] overflow-y-auto"
>
{
attachmentList
.
map
((
attachment
)
=>
{
return
(
<
div
key=
{
attachment
.
name
}
className=
"group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent"
>
<
SortableItem
id=
{
attachment
.
name
}
className=
"flex items-center gap-1.5 min-w-0"
>
{
getAttachmentType
(
attachment
)
===
"image/*"
?
(
<
img
src=
{
getAttachmentThumbnailUrl
(
attachment
)
}
alt=
{
attachment
.
filename
}
className=
"w-5 h-5 shrink-0 object-cover rounded"
/>
)
:
(
<
FileIcon
className=
"w-3.5 h-3.5 shrink-0 text-muted-foreground"
/>
)
}
<
span
className=
"truncate max-w-[160px]"
>
{
attachment
.
filename
}
</
span
>
</
SortableItem
>
<
button
className=
"shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onClick=
{
()
=>
handleDeleteAttachment
(
attachment
.
name
)
}
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-foreground"
/>
</
button
>
</
div
>
);
})
}
</
div
>
)
}
</
SortableContext
>
</
DndContext
>
);
};
export
default
AttachmentListView
;
web/src/components/MemoEditor/LocationView.tsx
deleted
100644 → 0
View file @
a2ccf6b2
import
{
MapPinIcon
,
XIcon
}
from
"lucide-react"
;
import
{
Location
}
from
"@/types/proto/api/v1/memo_service"
;
interface
Props
{
location
?:
Location
;
onRemove
:
()
=>
void
;
}
const
LocationView
=
(
props
:
Props
)
=>
{
if
(
!
props
.
location
)
{
return
null
;
}
return
(
<
div
className=
"w-full flex flex-row flex-wrap gap-2 mt-2"
>
<
div
className=
"group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent"
>
<
MapPinIcon
className=
"w-3.5 h-3.5 shrink-0 text-muted-foreground"
/>
<
span
className=
"truncate max-w-[160px]"
>
{
props
.
location
.
placeholder
}
</
span
>
<
button
className=
"shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
e
.
stopPropagation
();
props
.
onRemove
();
}
}
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-foreground"
/>
</
button
>
</
div
>
</
div
>
);
};
export
default
LocationView
;
web/src/components/MemoEditor/RelationListView.tsx
deleted
100644 → 0
View file @
a2ccf6b2
import
{
LinkIcon
,
XIcon
}
from
"lucide-react"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
memoStore
}
from
"@/store"
;
import
{
Memo
,
MemoRelation
,
MemoRelation_Type
}
from
"@/types/proto/api/v1/memo_service"
;
interface
Props
{
relationList
:
MemoRelation
[];
setRelationList
:
(
relationList
:
MemoRelation
[])
=>
void
;
}
const
RelationListView
=
observer
((
props
:
Props
)
=>
{
const
{
relationList
,
setRelationList
}
=
props
;
const
[
referencingMemoList
,
setReferencingMemoList
]
=
useState
<
Memo
[]
>
([]);
useEffect
(()
=>
{
(
async
()
=>
{
const
requests
=
relationList
.
filter
((
relation
)
=>
relation
.
type
===
MemoRelation_Type
.
REFERENCE
)
.
map
(
async
(
relation
)
=>
{
return
await
memoStore
.
getOrFetchMemoByName
(
relation
.
relatedMemo
!
.
name
,
{
skipStore
:
true
});
});
const
list
=
await
Promise
.
all
(
requests
);
setReferencingMemoList
(
list
);
})();
},
[
relationList
]);
const
handleDeleteRelation
=
async
(
memo
:
Memo
)
=>
{
setRelationList
(
relationList
.
filter
((
relation
)
=>
relation
.
relatedMemo
?.
name
!==
memo
.
name
));
};
return
(
<>
{
referencingMemoList
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-row gap-2 mt-2 flex-wrap"
>
{
referencingMemoList
.
map
((
memo
)
=>
{
return
(
<
div
key=
{
memo
.
name
}
className=
"group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent cursor-pointer"
onClick=
{
()
=>
handleDeleteRelation
(
memo
)
}
>
<
LinkIcon
className=
"w-3.5 h-3.5 shrink-0 text-muted-foreground"
/>
<
span
className=
"truncate max-w-[160px]"
>
{
memo
.
snippet
}
</
span
>
<
XIcon
className=
"w-3 h-3 shrink-0 text-muted-foreground"
/>
</
div
>
);
})
}
</
div
>
)
}
</>
);
});
export
default
RelationListView
;
web/src/components/MemoEditor/index.tsx
View file @
fc43f865
...
...
@@ -20,12 +20,10 @@ import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/t
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
convertVisibilityFromString
}
from
"@/utils/memo"
;
import
DateTimeInput
from
"../DateTimeInput"
;
import
{
LocationDisplay
,
AttachmentList
,
RelationList
}
from
"../memo-metadata"
;
import
InsertMenu
from
"./ActionButton/InsertMenu"
;
import
VisibilitySelector
from
"./ActionButton/VisibilitySelector"
;
import
AttachmentListView
from
"./AttachmentListView"
;
import
Editor
,
{
EditorRefActions
}
from
"./Editor"
;
import
LocationView
from
"./LocationView"
;
import
RelationListView
from
"./RelationListView"
;
import
{
handleEditorKeydownWithMarkdownShortcuts
,
hyperlinkHighlightedText
}
from
"./handlers"
;
import
{
MemoEditorContext
}
from
"./types"
;
...
...
@@ -490,7 +488,8 @@ const MemoEditor = observer((props: Props) => {
onCompositionEnd=
{
handleCompositionEnd
}
>
<
Editor
ref=
{
editorRef
}
{
...
editorConfig
}
/>
<
LocationView
<
LocationDisplay
mode=
"edit"
location=
{
state
.
location
}
onRemove=
{
()
=>
setState
((
prevState
)
=>
({
...
...
@@ -499,8 +498,8 @@ const MemoEditor = observer((props: Props) => {
}))
}
/>
<
AttachmentList
View
attachmentList=
{
state
.
attachmentList
}
setAttachmentList
=
{
handleSetAttachmentList
}
/>
<
RelationList
View
relationList=
{
referenceRelations
}
setRelationList
=
{
handleSetRelationList
}
/>
<
AttachmentList
mode=
"edit"
attachments=
{
state
.
attachmentList
}
onAttachmentsChange
=
{
handleSetAttachmentList
}
/>
<
RelationList
mode=
"edit"
relations=
{
referenceRelations
}
onRelationsChange
=
{
handleSetRelationList
}
/>
<
div
className=
"relative w-full flex flex-row justify-between items-center pt-2 gap-2"
onFocus=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
div
className=
"flex flex-row justify-start items-center gap-1"
>
<
InsertMenu
...
...
web/src/components/MemoLocationView.tsx
deleted
100644 → 0
View file @
a2ccf6b2
import
{
LatLng
}
from
"leaflet"
;
import
{
MapPinIcon
}
from
"lucide-react"
;
import
{
useState
}
from
"react"
;
import
{
Location
}
from
"@/types/proto/api/v1/memo_service"
;
import
LeafletMap
from
"./LeafletMap"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"./ui/popover"
;
interface
Props
{
location
:
Location
;
}
const
MemoLocationView
:
React
.
FC
<
Props
>
=
(
props
:
Props
)
=>
{
const
{
location
}
=
props
;
const
[
popoverOpen
,
setPopoverOpen
]
=
useState
<
boolean
>
(
false
);
return
(
<
Popover
open=
{
popoverOpen
}
onOpenChange=
{
setPopoverOpen
}
>
<
PopoverTrigger
asChild
>
<
p
className=
"w-full flex flex-row gap-0.5 items-center text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
<
MapPinIcon
className=
"w-4 h-auto shrink-0"
/>
<
span
className=
"text-sm font-normal text-ellipsis whitespace-nowrap overflow-hidden"
>
{
location
.
placeholder
?
location
.
placeholder
:
`[${location.latitude}, ${location.longitude}]`
}
</
span
>
</
p
>
</
PopoverTrigger
>
<
PopoverContent
align=
"start"
>
<
div
className=
"min-w-80 sm:w-lg flex flex-col justify-start items-start"
>
<
LeafletMap
latlng=
{
new
LatLng
(
location
.
latitude
,
location
.
longitude
)
}
readonly=
{
true
}
/>
</
div
>
</
PopoverContent
>
</
Popover
>
);
};
export
default
MemoLocationView
;
web/src/components/MemoRelationListView.tsx
deleted
100644 → 0
View file @
a2ccf6b2
import
{
LinkIcon
,
MilestoneIcon
}
from
"lucide-react"
;
import
{
memo
,
useState
}
from
"react"
;
import
{
Link
}
from
"react-router-dom"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
extractMemoIdFromName
}
from
"@/store/common"
;
import
{
Memo
,
MemoRelation
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
interface
Props
{
memo
:
Memo
;
relations
:
MemoRelation
[];
parentPage
?:
string
;
}
const
MemoRelationListView
=
(
props
:
Props
)
=>
{
const
t
=
useTranslate
();
const
{
memo
,
relations
:
relationList
,
parentPage
}
=
props
;
const
referencingMemoList
=
relationList
.
filter
((
relation
)
=>
relation
.
memo
?.
name
===
memo
.
name
&&
relation
.
relatedMemo
?.
name
!==
memo
.
name
)
.
map
((
relation
)
=>
relation
.
relatedMemo
!
);
const
referencedMemoList
=
relationList
.
filter
((
relation
)
=>
relation
.
memo
?.
name
!==
memo
.
name
&&
relation
.
relatedMemo
?.
name
===
memo
.
name
)
.
map
((
relation
)
=>
relation
.
memo
!
);
const
[
selectedTab
,
setSelectedTab
]
=
useState
<
"referencing"
|
"referenced"
>
(
referencingMemoList
.
length
===
0
?
"referenced"
:
"referencing"
,
);
if
(
referencingMemoList
.
length
+
referencedMemoList
.
length
===
0
)
{
return
null
;
}
return
(
<
div
className=
"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"
>
<
div
className=
"w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60"
>
{
referencingMemoList
.
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"
,
selectedTab
===
"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"
>
(
{
referencingMemoList
.
length
}
)
</
span
>
</
button
>
)
}
{
referencedMemoList
.
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"
,
selectedTab
===
"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"
>
(
{
referencedMemoList
.
length
}
)
</
span
>
</
button
>
)
}
</
div
>
{
selectedTab
===
"referencing"
&&
referencingMemoList
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-col justify-start items-start"
>
{
referencingMemoList
.
map
((
memo
)
=>
{
return
(
<
Link
key=
{
memo
.
name
}
className=
"w-full flex flex-row justify-start items-center text-sm leading-5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-2 py-1 transition-colors"
to=
{
`/${memo.name}`
}
viewTransition
state=
{
{
from
:
parentPage
,
}
}
>
<
span
className=
"text-xs opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1"
>
{
extractMemoIdFromName
(
memo
.
name
).
slice
(
0
,
6
)
}
</
span
>
<
span
className=
"truncate"
>
{
memo
.
snippet
}
</
span
>
</
Link
>
);
})
}
</
div
>
)
}
{
selectedTab
===
"referenced"
&&
referencedMemoList
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-col justify-start items-start"
>
{
referencedMemoList
.
map
((
memo
)
=>
{
return
(
<
Link
key=
{
memo
.
name
}
className=
"w-full flex flex-row justify-start items-center text-sm leading-5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-2 py-1 transition-colors"
to=
{
`/${memo.name}`
}
viewTransition
state=
{
{
from
:
parentPage
,
}
}
>
<
span
className=
"text-xs opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1"
>
{
extractMemoIdFromName
(
memo
.
name
).
slice
(
0
,
6
)
}
</
span
>
<
span
className=
"truncate"
>
{
memo
.
snippet
}
</
span
>
</
Link
>
);
})
}
</
div
>
)
}
</
div
>
);
};
export
default
memo
(
MemoRelationListView
);
web/src/components/MemoView.tsx
View file @
fc43f865
...
...
@@ -14,16 +14,14 @@ import { useTranslate } from "@/utils/i18n";
import
{
convertVisibilityToString
}
from
"@/utils/memo"
;
import
{
isSuperUser
}
from
"@/utils/user"
;
import
MemoActionMenu
from
"./MemoActionMenu"
;
import
MemoAttachmentListView
from
"./MemoAttachmentListView"
;
import
MemoContent
from
"./MemoContent"
;
import
MemoEditor
from
"./MemoEditor"
;
import
MemoLocationView
from
"./MemoLocationView"
;
import
MemoReactionistView
from
"./MemoReactionListView"
;
import
MemoRelationListView
from
"./MemoRelationListView"
;
import
PreviewImageDialog
from
"./PreviewImageDialog"
;
import
ReactionSelector
from
"./ReactionSelector"
;
import
UserAvatar
from
"./UserAvatar"
;
import
VisibilityIcon
from
"./VisibilityIcon"
;
import
{
LocationDisplay
,
AttachmentList
,
RelationList
}
from
"./memo-metadata"
;
interface
Props
{
memo
:
Memo
;
...
...
@@ -256,9 +254,9 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
compact=
{
memo
.
pinned
?
false
:
props
.
compact
}
// Always show full content when pinned.
parentPage=
{
parentPage
}
/>
{
memo
.
location
&&
<
MemoLocationView
location=
{
memo
.
location
}
/>
}
<
MemoAttachmentListView
attachments=
{
memo
.
attachments
}
/>
<
MemoRelationListView
memo=
{
memo
}
relations=
{
referencedMemos
}
parentPage=
{
parentPage
}
/>
{
memo
.
location
&&
<
LocationDisplay
mode=
"view"
location=
{
memo
.
location
}
/>
}
<
AttachmentList
mode=
"view"
attachments=
{
memo
.
attachments
}
/>
<
RelationList
mode=
"view"
relations=
{
referencedMemos
}
currentMemoName=
{
memo
.
name
}
parentPage=
{
parentPage
}
/>
<
MemoReactionistView
memo=
{
memo
}
reactions=
{
memo
.
reactions
}
/>
</
div
>
{
nsfw
&&
!
showNSFWContent
&&
(
...
...
web/src/components/memo-metadata/AttachmentCard.tsx
0 → 100644
View file @
fc43f865
import
{
FileIcon
,
XIcon
}
from
"lucide-react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service"
;
import
{
getAttachmentThumbnailUrl
,
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
{
DisplayMode
}
from
"./types"
;
interface
AttachmentCardProps
{
attachment
:
Attachment
;
mode
:
DisplayMode
;
onRemove
?:
()
=>
void
;
onClick
?:
()
=>
void
;
className
?:
string
;
showThumbnail
?:
boolean
;
}
/**
* Shared attachment card component
* Displays thumbnails for images in both modes, with size variations
*/
const
AttachmentCard
=
({
attachment
,
mode
,
onRemove
,
onClick
,
className
,
showThumbnail
=
true
}:
AttachmentCardProps
)
=>
{
const
type
=
getAttachmentType
(
attachment
);
const
attachmentUrl
=
getAttachmentUrl
(
attachment
);
const
attachmentThumbnailUrl
=
getAttachmentThumbnailUrl
(
attachment
);
const
isMedia
=
type
===
"image/*"
||
type
===
"video/*"
;
// Editor mode - compact badge style with thumbnail
if
(
mode
===
"edit"
)
{
return
(
<
div
className=
{
cn
(
"group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent"
,
className
,
)
}
>
{
showThumbnail
&&
type
===
"image/*"
?
(
<
img
src=
{
attachmentThumbnailUrl
}
alt=
{
attachment
.
filename
}
className=
"w-5 h-5 shrink-0 object-cover rounded"
/>
)
:
(
<
FileIcon
className=
"w-3.5 h-3.5 shrink-0 text-muted-foreground"
/>
)
}
<
span
className=
"truncate max-w-[160px]"
>
{
attachment
.
filename
}
</
span
>
{
onRemove
&&
(
<
button
className=
"shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
e
.
stopPropagation
();
onRemove
();
}
}
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-foreground"
/>
</
button
>
)
}
</
div
>
);
}
// View mode - media gets special treatment
if
(
isMedia
)
{
if
(
type
===
"image/*"
)
{
return
(
<
img
className=
{
cn
(
"cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain transition-colors"
,
className
)
}
src=
{
attachmentThumbnailUrl
}
onError=
{
(
e
)
=>
{
const
target
=
e
.
target
as
HTMLImageElement
;
if
(
target
.
src
.
includes
(
"?thumbnail=true"
))
{
target
.
src
=
attachmentUrl
;
}
}
}
onClick=
{
onClick
}
decoding=
"async"
loading=
"lazy"
alt=
{
attachment
.
filename
}
/>
);
}
else
if
(
type
===
"video/*"
)
{
return
(
<
video
className=
{
cn
(
"cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain bg-muted transition-colors"
,
className
,
)
}
preload=
"metadata"
crossOrigin=
"anonymous"
src=
{
attachmentUrl
}
controls
/>
);
}
}
// View mode - non-media files (will be handled by parent component for proper file card display)
return
null
;
};
export
default
AttachmentCard
;
web/src/components/memo-metadata/AttachmentList.tsx
0 → 100644
View file @
fc43f865
import
{
DndContext
,
closestCenter
,
MouseSensor
,
TouchSensor
,
useSensor
,
useSensors
,
DragEndEvent
}
from
"@dnd-kit/core"
;
import
{
arrayMove
,
SortableContext
,
verticalListSortingStrategy
}
from
"@dnd-kit/sortable"
;
import
{
useState
}
from
"react"
;
import
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service"
;
import
{
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
MemoAttachment
from
"../MemoAttachment"
;
import
SortableItem
from
"../MemoEditor/SortableItem"
;
import
PreviewImageDialog
from
"../PreviewImageDialog"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
{
BaseMetadataProps
}
from
"./types"
;
interface
AttachmentListProps
extends
BaseMetadataProps
{
attachments
:
Attachment
[];
onAttachmentsChange
?:
(
attachments
:
Attachment
[])
=>
void
;
}
/**
* Unified AttachmentList component for both editor and view modes
*
* Editor mode:
* - Shows all attachments as sortable badges with thumbnails
* - Supports drag-and-drop reordering
* - Shows remove buttons
*
* View mode:
* - Separates media (images/videos) from other files
* - Shows media in gallery layout with preview
* - Shows other files as clickable cards
*/
const
AttachmentList
=
({
attachments
,
mode
,
onAttachmentsChange
}:
AttachmentListProps
)
=>
{
const
sensors
=
useSensors
(
useSensor
(
MouseSensor
),
useSensor
(
TouchSensor
));
const
[
previewImage
,
setPreviewImage
]
=
useState
<
{
open
:
boolean
;
urls
:
string
[];
index
:
number
}
>
({
open
:
false
,
urls
:
[],
index
:
0
,
});
const
handleDeleteAttachment
=
(
name
:
string
)
=>
{
if
(
onAttachmentsChange
)
{
onAttachmentsChange
(
attachments
.
filter
((
attachment
)
=>
attachment
.
name
!==
name
));
}
};
const
handleDragEnd
=
(
event
:
DragEndEvent
)
=>
{
const
{
active
,
over
}
=
event
;
if
(
over
&&
active
.
id
!==
over
.
id
&&
onAttachmentsChange
)
{
const
oldIndex
=
attachments
.
findIndex
((
attachment
)
=>
attachment
.
name
===
active
.
id
);
const
newIndex
=
attachments
.
findIndex
((
attachment
)
=>
attachment
.
name
===
over
.
id
);
onAttachmentsChange
(
arrayMove
(
attachments
,
oldIndex
,
newIndex
));
}
};
const
handleImageClick
=
(
imgUrl
:
string
,
mediaAttachments
:
Attachment
[])
=>
{
const
imgUrls
=
mediaAttachments
.
filter
((
attachment
)
=>
getAttachmentType
(
attachment
)
===
"image/*"
)
.
map
((
attachment
)
=>
getAttachmentUrl
(
attachment
));
const
index
=
imgUrls
.
findIndex
((
url
)
=>
url
===
imgUrl
);
setPreviewImage
({
open
:
true
,
urls
:
imgUrls
,
index
});
};
// Editor mode: Show all attachments as sortable badges
if
(
mode
===
"edit"
)
{
if
(
attachments
.
length
===
0
)
{
return
null
;
}
return
(
<
DndContext
sensors=
{
sensors
}
collisionDetection=
{
closestCenter
}
onDragEnd=
{
handleDragEnd
}
>
<
SortableContext
items=
{
attachments
.
map
((
attachment
)
=>
attachment
.
name
)
}
strategy=
{
verticalListSortingStrategy
}
>
<
div
className=
"w-full flex flex-row justify-start flex-wrap gap-2 mt-2 max-h-[50vh] overflow-y-auto"
>
{
attachments
.
map
((
attachment
)
=>
(
<
div
key=
{
attachment
.
name
}
>
<
SortableItem
id=
{
attachment
.
name
}
className=
"flex items-center gap-1.5 min-w-0"
>
<
AttachmentCard
attachment=
{
attachment
}
mode=
"edit"
onRemove=
{
()
=>
handleDeleteAttachment
(
attachment
.
name
)
}
showThumbnail=
{
true
}
/>
</
SortableItem
>
</
div
>
))
}
</
div
>
</
SortableContext
>
</
DndContext
>
);
}
// View mode: Separate media from other files
const
mediaAttachments
:
Attachment
[]
=
[];
const
otherAttachments
:
Attachment
[]
=
[];
attachments
.
forEach
((
attachment
)
=>
{
const
type
=
getAttachmentType
(
attachment
);
if
(
type
===
"image/*"
||
type
===
"video/*"
)
{
mediaAttachments
.
push
(
attachment
);
}
else
{
otherAttachments
.
push
(
attachment
);
}
});
return
(
<>
{
/* Media Gallery */
}
{
mediaAttachments
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
{
mediaAttachments
.
map
((
attachment
)
=>
(
<
div
key=
{
attachment
.
name
}
className=
"max-w-[60%] w-fit flex flex-col justify-start items-start shrink-0"
>
<
AttachmentCard
attachment=
{
attachment
}
mode=
"view"
onClick=
{
()
=>
{
const
attachmentUrl
=
getAttachmentUrl
(
attachment
);
handleImageClick
(
attachmentUrl
,
mediaAttachments
);
}
}
className=
"max-h-64 grow"
/>
</
div
>
))
}
</
div
>
)
}
{
/* Other Files */
}
{
otherAttachments
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
{
otherAttachments
.
map
((
attachment
)
=>
(
<
MemoAttachment
key=
{
attachment
.
name
}
attachment=
{
attachment
}
/>
))
}
</
div
>
)
}
{
/* Image Preview Dialog */
}
<
PreviewImageDialog
open=
{
previewImage
.
open
}
onOpenChange=
{
(
open
)
=>
setPreviewImage
((
prev
)
=>
({
...
prev
,
open
}))
}
imgUrls=
{
previewImage
.
urls
}
initialIndex=
{
previewImage
.
index
}
/>
</>
);
};
export
default
AttachmentList
;
web/src/components/memo-metadata/LocationDisplay.tsx
0 → 100644
View file @
fc43f865
import
{
LatLng
}
from
"leaflet"
;
import
{
ExternalLinkIcon
,
MapPinIcon
}
from
"lucide-react"
;
import
{
useState
}
from
"react"
;
import
{
Location
}
from
"@/types/proto/api/v1/memo_service"
;
import
LeafletMap
from
"../LeafletMap"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"../ui/popover"
;
import
MetadataBadge
from
"./MetadataBadge"
;
import
{
BaseMetadataProps
}
from
"./types"
;
interface
LocationDisplayProps
extends
BaseMetadataProps
{
location
?:
Location
;
onRemove
?:
()
=>
void
;
onClick
?:
()
=>
void
;
}
/**
* Unified Location component for both editor and view modes
*
* Editor mode: Shows badge with remove button
* View mode: Shows badge with popover map on click
*/
const
LocationDisplay
=
({
location
,
mode
,
onRemove
,
onClick
,
className
}:
LocationDisplayProps
)
=>
{
const
[
popoverOpen
,
setPopoverOpen
]
=
useState
<
boolean
>
(
false
);
if
(
!
location
)
{
return
null
;
}
const
displayText
=
location
.
placeholder
||
`[
${
location
.
latitude
}
,
${
location
.
longitude
}
]`
;
// Editor mode: Simple badge with remove button
if
(
mode
===
"edit"
)
{
return
(
<
div
className=
"w-full flex flex-row flex-wrap gap-2 mt-2"
>
<
MetadataBadge
icon=
{
<
MapPinIcon
className=
"w-3.5 h-3.5"
/>
}
onRemove=
{
onRemove
}
onClick=
{
onClick
}
className=
{
className
}
>
{
displayText
}
</
MetadataBadge
>
</
div
>
);
}
// View mode: Badge with popover map
return
(
<
Popover
open=
{
popoverOpen
}
onOpenChange=
{
setPopoverOpen
}
>
<
PopoverTrigger
asChild
>
<
div
className=
"w-full flex flex-row flex-wrap gap-2"
>
<
MetadataBadge
icon=
{
<
MapPinIcon
className=
"w-3.5 h-3.5"
/>
}
onClick=
{
()
=>
setPopoverOpen
(
true
)
}
className=
{
className
}
>
<
span
>
{
displayText
}
</
span
>
<
ExternalLinkIcon
className=
"w-2.5 h-2.5 ml-1 opacity-50"
/>
</
MetadataBadge
>
</
div
>
</
PopoverTrigger
>
<
PopoverContent
align=
"start"
>
<
div
className=
"min-w-80 sm:w-lg flex flex-col justify-start items-start"
>
<
LeafletMap
latlng=
{
new
LatLng
(
location
.
latitude
,
location
.
longitude
)
}
readonly=
{
true
}
/>
</
div
>
</
PopoverContent
>
</
Popover
>
);
};
export
default
LocationDisplay
;
web/src/components/memo-metadata/MetadataBadge.tsx
0 → 100644
View file @
fc43f865
import
{
XIcon
}
from
"lucide-react"
;
import
{
ReactNode
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
interface
MetadataBadgeProps
{
icon
:
ReactNode
;
children
:
ReactNode
;
onRemove
?:
()
=>
void
;
onClick
?:
()
=>
void
;
className
?:
string
;
maxWidth
?:
string
;
}
/**
* Shared badge component for metadata display (Location, Tags, etc.)
* Provides consistent styling across editor and view modes
*/
const
MetadataBadge
=
({
icon
,
children
,
onRemove
,
onClick
,
className
,
maxWidth
=
"max-w-[160px]"
}:
MetadataBadgeProps
)
=>
{
return
(
<
div
className=
{
cn
(
"group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors"
,
onClick
&&
"cursor-pointer hover:bg-accent"
,
className
,
)
}
onClick=
{
onClick
}
>
<
span
className=
"shrink-0 text-muted-foreground"
>
{
icon
}
</
span
>
<
span
className=
{
cn
(
"truncate"
,
maxWidth
)
}
>
{
children
}
</
span
>
{
onRemove
&&
(
<
button
className=
"shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
e
.
stopPropagation
();
onRemove
();
}
}
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-foreground"
/>
</
button
>
)
}
</
div
>
);
};
export
default
MetadataBadge
;
web/src/components/memo-metadata/MetadataCard.tsx
0 → 100644
View file @
fc43f865
import
{
ReactNode
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
interface
MetadataCardProps
{
children
:
ReactNode
;
className
?:
string
;
}
/**
* Shared card component for structured metadata (Relations, Comments, etc.)
* Provides consistent card styling across editor and view modes
*/
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/memo-metadata/RelationCard.tsx
0 → 100644
View file @
fc43f865
import
{
LinkIcon
,
XIcon
}
from
"lucide-react"
;
import
{
Link
}
from
"react-router-dom"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
extractMemoIdFromName
}
from
"@/store/common"
;
import
{
MemoRelation_Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
DisplayMode
}
from
"./types"
;
interface
RelationCardProps
{
memo
:
MemoRelation_Memo
;
mode
:
DisplayMode
;
onRemove
?:
()
=>
void
;
parentPage
?:
string
;
className
?:
string
;
}
/**
* Shared relation card component for displaying linked memos
*
* Editor mode: Badge with remove button, click to remove
* View mode: Link with memo ID and snippet, click to navigate
*/
const
RelationCard
=
({
memo
,
mode
,
onRemove
,
parentPage
,
className
}:
RelationCardProps
)
=>
{
const
memoId
=
extractMemoIdFromName
(
memo
.
name
);
// Editor mode: Badge with remove
if
(
mode
===
"edit"
)
{
return
(
<
div
className=
{
cn
(
"group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent cursor-pointer"
,
className
,
)
}
onClick=
{
onRemove
}
>
<
LinkIcon
className=
"w-3.5 h-3.5 shrink-0 text-muted-foreground"
/>
<
span
className=
"truncate max-w-[160px]"
>
{
memo
.
snippet
}
</
span
>
<
XIcon
className=
"w-3 h-3 shrink-0 text-muted-foreground"
/>
</
div
>
);
}
// View mode: Navigable link with ID and snippet
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-2 py-1 transition-colors"
,
className
,
)
}
to=
{
`/${memo.name}`
}
viewTransition
state=
{
{
from
:
parentPage
,
}
}
>
<
span
className=
"text-xs opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1"
>
{
memoId
.
slice
(
0
,
6
)
}
</
span
>
<
span
className=
"truncate"
>
{
memo
.
snippet
}
</
span
>
</
Link
>
);
};
export
default
RelationCard
;
web/src/components/memo-metadata/RelationList.tsx
0 → 100644
View file @
fc43f865
import
{
LinkIcon
,
MilestoneIcon
}
from
"lucide-react"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
memoStore
}
from
"@/store"
;
import
{
Memo
,
MemoRelation
,
MemoRelation_Type
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
MetadataCard
from
"./MetadataCard"
;
import
RelationCard
from
"./RelationCard"
;
import
{
BaseMetadataProps
}
from
"./types"
;
interface
RelationListProps
extends
BaseMetadataProps
{
relations
:
MemoRelation
[];
currentMemoName
?:
string
;
onRelationsChange
?:
(
relations
:
MemoRelation
[])
=>
void
;
parentPage
?:
string
;
}
/**
* Unified RelationList component for both editor and view modes
*
* Editor mode:
* - Shows only outgoing relations (referencing)
* - Badge-style display with remove buttons
* - Compact inline layout
*
* View mode:
* - Shows bidirectional relations in tabbed card
* - "Referencing" tab: Memos this memo links to
* - "Referenced by" tab: Memos that link to this memo
* - Navigable links with memo IDs
*/
const
RelationList
=
observer
(({
relations
,
currentMemoName
,
mode
,
onRelationsChange
,
parentPage
,
className
}:
RelationListProps
)
=>
{
const
t
=
useTranslate
();
const
[
referencingMemos
,
setReferencingMemos
]
=
useState
<
Memo
[]
>
([]);
const
[
selectedTab
,
setSelectedTab
]
=
useState
<
"referencing"
|
"referenced"
>
(
"referencing"
);
// Get referencing and referenced relations
const
referencingRelations
=
relations
.
filter
(
(
relation
)
=>
relation
.
type
===
MemoRelation_Type
.
REFERENCE
&&
(
mode
===
"edit"
||
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
,
);
// Fetch full memo details for editor mode
useEffect
(()
=>
{
if
(
mode
===
"edit"
&&
referencingRelations
.
length
>
0
)
{
(
async
()
=>
{
const
requests
=
referencingRelations
.
map
(
async
(
relation
)
=>
{
return
await
memoStore
.
getOrFetchMemoByName
(
relation
.
relatedMemo
!
.
name
,
{
skipStore
:
true
});
});
const
list
=
await
Promise
.
all
(
requests
);
setReferencingMemos
(
list
);
})();
}
},
[
mode
,
relations
]);
const
handleDeleteRelation
=
(
memoName
:
string
)
=>
{
if
(
onRelationsChange
)
{
onRelationsChange
(
relations
.
filter
((
relation
)
=>
relation
.
relatedMemo
?.
name
!==
memoName
));
}
};
// Editor mode: Simple badge list
if
(
mode
===
"edit"
)
{
if
(
referencingMemos
.
length
===
0
)
{
return
null
;
}
return
(
<
div
className=
"w-full flex flex-row gap-2 mt-2 flex-wrap"
>
{
referencingMemos
.
map
((
memo
)
=>
(
<
RelationCard
key=
{
memo
.
name
}
memo=
{
{
name
:
memo
.
name
,
snippet
:
memo
.
snippet
}
}
mode=
"edit"
onRemove=
{
()
=>
handleDeleteRelation
(
memo
.
name
)
}
/>
))
}
</
div
>
);
}
// View mode: Tabbed card with bidirectional relations
if
(
referencingRelations
.
length
===
0
&&
referencedRelations
.
length
===
0
)
{
return
null
;
}
// Auto-select tab based on which has content
const
activeTab
=
referencingRelations
.
length
===
0
?
"referenced"
:
selectedTab
;
return
(
<
MetadataCard
className=
{
className
}
>
{
/* Tabs */
}
<
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
>
{
/* Referencing List */
}
{
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
!
}
mode=
"view"
parentPage=
{
parentPage
}
/>
))
}
</
div
>
)
}
{
/* Referenced List */
}
{
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
!
}
mode=
"view"
parentPage=
{
parentPage
}
/>
))
}
</
div
>
)
}
</
MetadataCard
>
);
});
export
default
RelationList
;
web/src/components/memo-metadata/index.ts
0 → 100644
View file @
fc43f865
/**
* Unified memo metadata components
* Provides consistent styling and behavior across editor and view modes
*/
export
{
default
as
LocationDisplay
}
from
"./LocationDisplay"
;
export
{
default
as
AttachmentList
}
from
"./AttachmentList"
;
export
{
default
as
RelationList
}
from
"./RelationList"
;
// Base components (can be used for other metadata types)
export
{
default
as
MetadataBadge
}
from
"./MetadataBadge"
;
export
{
default
as
MetadataCard
}
from
"./MetadataCard"
;
export
{
default
as
AttachmentCard
}
from
"./AttachmentCard"
;
export
{
default
as
RelationCard
}
from
"./RelationCard"
;
// Types
export
type
{
DisplayMode
,
BaseMetadataProps
}
from
"./types"
;
web/src/components/memo-metadata/types.ts
0 → 100644
View file @
fc43f865
/**
* Common types for memo metadata components
*/
export
type
DisplayMode
=
"edit"
|
"view"
;
export
interface
BaseMetadataProps
{
mode
:
DisplayMode
;
className
?:
string
;
}
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